Panel/Panel/modules/addonsmanager/server_content_helpers.php
2026-06-08 16:09:54 -05:00

1268 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
*
* GSP - Server Content helpers (addonsmanager)
*
*/
if (!defined('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT')) {
define('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT', 'generic_steam_workshop_linux.sh');
}
if (!defined('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT')) {
define('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT', 'generic_steam_workshop_windows_cygwin.sh');
}
if (!defined('SCM_WORKSHOP_TARGET_GENERIC_DEFAULT')) {
define('SCM_WORKSHOP_TARGET_GENERIC_DEFAULT', '{SERVER_ROOT}/workshop/{MOD_FOLDER}');
}
if (!defined('SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT')) {
define('SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT', '{SERVER_ROOT}/{MOD_FOLDER}');
}
function scm_ensure_workshop_schema($db)
{
static $schema_checked = false;
if ($schema_checked) {
return true;
}
$schema_checked = true;
$db->query("ALTER TABLE `".OGP_DB_PREFIX."addons` MODIFY `addon_type` VARCHAR(32) NOT NULL");
$ok = (bool)$db->query(
"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`home_id` INT NOT NULL,
`home_cfg_id` INT NOT NULL,
`remote_server_id` INT NULL,
`workshop_app_id` VARCHAR(32) NULL,
`workshop_item_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) NULL,
`install_path` VARCHAR(512) NULL,
`install_strategy` VARCHAR(64) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`load_order` INT NOT NULL DEFAULT 0,
`install_state` VARCHAR(32) NOT NULL DEFAULT 'selected',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
`last_error` TEXT NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
UNIQUE KEY `uniq_home_workshop_item` (`home_id`, `workshop_item_id`),
KEY `idx_home_id` (`home_id`),
KEY `idx_home_cfg_id` (`home_cfg_id`),
KEY `idx_install_state` (`install_state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
// Idempotently add content_id column (db_version 6).
$wk_table = $db->realEscapeSingle(OGP_DB_PREFIX . 'server_content_workshop');
$col_check = $db->resultQuery(
"SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$wk_table}'
AND COLUMN_NAME = 'content_id'"
);
if (empty($col_check)) {
$db->query(
"ALTER TABLE `".OGP_DB_PREFIX."server_content_workshop`
ADD COLUMN `content_id` INT NULL AFTER `id`,
ADD KEY `idx_content_id` (`content_id`)"
);
}
$workshop_columns = array(
'install_path' => "VARCHAR(512) NULL AFTER `title`",
'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`",
'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`",
'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`",
'update_policy' => "VARCHAR(64) NOT NULL DEFAULT 'manual' AFTER `load_order`",
'pending_action' => "VARCHAR(64) NULL AFTER `update_policy`",
);
foreach ($workshop_columns as $col => $definition) {
$escaped_col = $db->realEscapeSingle($col);
$col_check = $db->resultQuery(
"SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$wk_table}'
AND COLUMN_NAME = '{$escaped_col}'"
);
if (empty($col_check)) {
$db->query("ALTER TABLE `".OGP_DB_PREFIX."server_content_workshop` ADD COLUMN `{$col}` {$definition}");
}
}
$db->query(
"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop_catalog` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`workshop_id` VARCHAR(64) NOT NULL,
`app_id` VARCHAR(32) NOT NULL DEFAULT '',
`title` VARCHAR(255) NULL,
`install_count` INT NOT NULL DEFAULT 0,
`first_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_installed` DATETIME NULL,
`last_updated` DATETIME NULL,
`published_date` DATETIME NULL,
`tags` TEXT NULL,
`game_key` VARCHAR(128) NULL,
`local_cache_path` VARCHAR(512) NULL,
UNIQUE KEY `uniq_workshop_app` (`workshop_id`, `app_id`),
KEY `idx_app_id` (`app_id`),
KEY `idx_install_count` (`install_count`),
KEY `idx_last_installed` (`last_installed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return $ok;
}
function scm_get_home_for_user($db, $home_id, $user_id)
{
$home_id = (int)$home_id;
$user_id = (int)$user_id;
if ($home_id <= 0 || $user_id <= 0) {
return false;
}
if ($db->isAdmin($user_id)) {
return $db->getGameHome($home_id);
}
return $db->getUserGameHome($user_id, $home_id);
}
function scm_get_workshop_saved_count($db, $home_id)
{
$home_id = (int)$home_id;
if ($home_id <= 0 || !scm_ensure_workshop_schema($db)) {
return 0;
}
$rows = $db->resultQuery(
"SELECT COUNT(*) AS cnt FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." AND install_state<>'removed'"
);
if (!is_array($rows) || !isset($rows[0]['cnt'])) {
return 0;
}
return (int)$rows[0]['cnt'];
}
function scm_get_workshop_rows($db, $home_id)
{
$home_id = (int)$home_id;
if ($home_id <= 0 || !scm_ensure_workshop_schema($db)) {
return array();
}
$rows = $db->resultQuery(
"SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." ORDER BY load_order ASC, created_at DESC, workshop_item_id ASC"
);
return is_array($rows) ? $rows : array();
}
function scm_get_workshop_update_policies()
{
return array(
'manual' => 'Manual update only',
'scheduled' => 'Update on scheduled task',
'update_now' => 'Update immediately',
'update_and_restart' => 'Update immediately and restart',
'download_only' => 'Download now, install later',
'install_on_restart' => 'Install during next restart',
);
}
function scm_normalize_workshop_update_policy($policy)
{
$policy = trim((string)$policy);
$policies = scm_get_workshop_update_policies();
return isset($policies[$policy]) ? $policy : 'manual';
}
function scm_workshop_set_enabled($db, $home_id, array $item_ids, $enabled)
{
if (empty($item_ids)) {
return false;
}
$escaped_ids = array();
foreach ($item_ids as $item_id) {
$escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'";
}
return (bool)$db->query(
"UPDATE `".OGP_DB_PREFIX."server_content_workshop`
SET enabled=" . ((int)$enabled ? 1 : 0) . ", updated_at=NOW()
WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")"
);
}
function scm_workshop_set_update_policy($db, $home_id, array $item_ids, $policy)
{
if (empty($item_ids)) {
return false;
}
$policy = scm_normalize_workshop_update_policy($policy);
$pending = '';
if ($policy === 'download_only') {
$pending = 'install_on_restart';
}
elseif ($policy === 'install_on_restart') {
$pending = 'install_on_restart';
}
$escaped_ids = array();
foreach ($item_ids as $item_id) {
$escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'";
}
return (bool)$db->query(
"UPDATE `".OGP_DB_PREFIX."server_content_workshop`
SET update_policy='" . $db->realEscapeSingle($policy) . "',
pending_action=" . ($pending === '' ? "NULL" : "'" . $db->realEscapeSingle($pending) . "'") . ",
updated_at=NOW()
WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")"
);
}
function scm_workshop_catalog_sort_sql($sort)
{
$sort = trim((string)$sort);
$allowed = array(
'name' => 'title ASC, workshop_id ASC',
'install_count' => 'install_count DESC, last_installed DESC, workshop_id ASC',
'published_date' => 'published_date DESC, title ASC, workshop_id ASC',
'last_updated' => 'last_updated DESC, title ASC, workshop_id ASC',
'last_installed' => 'last_installed DESC, title ASC, workshop_id ASC',
);
return isset($allowed[$sort]) ? $allowed[$sort] : $allowed['last_installed'];
}
function scm_get_workshop_catalog_rows($db, $app_id = '', $sort = 'last_installed', $limit = 50)
{
if (!scm_ensure_workshop_schema($db)) {
return array();
}
$where = '';
$app_id = trim((string)$app_id);
if ($app_id !== '' && preg_match('/^[0-9]+$/', $app_id)) {
$where = "WHERE app_id='" . $db->realEscapeSingle($app_id) . "'";
}
$limit = (int)$limit;
if ($limit <= 0 || $limit > 200) {
$limit = 50;
}
$rows = $db->resultQuery(
"SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop_catalog`
{$where}
ORDER BY " . scm_workshop_catalog_sort_sql($sort) . "
LIMIT {$limit}"
);
return is_array($rows) ? $rows : array();
}
function scm_workshop_record_catalog_items($db, $workshop_app_id, array $item_ids, array $home_info = array(), array $item_details = array(), $mark_update = false)
{
if (empty($item_ids) || !scm_ensure_workshop_schema($db)) {
return false;
}
$workshop_app_id = preg_match('/^[0-9]+$/', (string)$workshop_app_id) ? (string)$workshop_app_id : '';
$game_key = isset($home_info['game_key']) ? (string)$home_info['game_key'] : '';
foreach ($item_ids as $item_id) {
$item_id = (string)$item_id;
if (!preg_match('/^[0-9]+$/', $item_id)) {
continue;
}
$detail = isset($item_details[$item_id]) && is_array($item_details[$item_id]) ? $item_details[$item_id] : array();
$title = isset($detail['title']) ? (string)$detail['title'] : '';
$install_path = isset($detail['target_path_resolved']) ? (string)$detail['target_path_resolved'] : '';
$db->query(
"INSERT INTO `".OGP_DB_PREFIX."server_content_workshop_catalog`
(workshop_id, app_id, title, install_count, first_seen, last_installed, last_updated, game_key, local_cache_path)
VALUES (
'".$db->realEscapeSingle($item_id)."',
'".$db->realEscapeSingle($workshop_app_id)."',
".($title === '' ? "NULL" : "'".$db->realEscapeSingle($title)."'").",
1,
NOW(),
NOW(),
".($mark_update ? "NOW()" : "NULL").",
".($game_key === '' ? "NULL" : "'".$db->realEscapeSingle($game_key)."'").",
".($install_path === '' ? "NULL" : "'".$db->realEscapeSingle($install_path)."'")."
)
ON DUPLICATE KEY UPDATE
install_count=install_count+1,
title=IF(VALUES(title) IS NULL OR VALUES(title)='', title, VALUES(title)),
last_installed=NOW(),
last_updated=".($mark_update ? "NOW()" : "last_updated").",
game_key=IF(VALUES(game_key) IS NULL OR VALUES(game_key)='', game_key, VALUES(game_key)),
local_cache_path=IF(VALUES(local_cache_path) IS NULL OR VALUES(local_cache_path)='', local_cache_path, VALUES(local_cache_path))"
);
}
return true;
}
function scm_extract_workshop_item_id($value)
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
if (preg_match('/^[0-9]{1,20}$/', $value)) {
return ltrim($value, '0') === '' ? '' : $value;
}
if (preg_match('/[?&]id=([0-9]{1,20})(?:[^0-9]|$)/i', $value, $matches)) {
return ltrim($matches[1], '0') === '' ? '' : $matches[1];
}
if (preg_match('/steamcommunity\.com\/(?:sharedfiles|workshop)\/filedetails\/\?[^ \t\r\n]*id=([0-9]{1,20})/i', $value, $matches)) {
return ltrim($matches[1], '0') === '' ? '' : $matches[1];
}
return '';
}
function scm_parse_workshop_ids($raw, &$invalid = array())
{
$invalid = array();
$ids = array();
// Accept IDs or full Steam Workshop URLs separated by commas, whitespace,
// or newlines. Customer input is reduced to numeric IDs before it reaches
// manifests, shell commands, or install paths.
$normalized = preg_replace('/[\r\n\t ]+/', ',', (string)$raw);
$parts = explode(',', (string)$normalized);
foreach ((array)$parts as $part) {
$value = trim((string)$part);
if ($value === '') {
continue;
}
$item_id = scm_extract_workshop_item_id($value);
if ($item_id === '') {
$invalid[] = $value;
continue;
}
$ids[$item_id] = $item_id;
}
return array_values($ids);
}
function scm_parse_selected_workshop_ids($selected)
{
$ids = array();
if (!is_array($selected)) {
return $ids;
}
foreach ($selected as $item_id) {
$item_id = trim((string)$item_id);
if ($item_id !== '' && preg_match('/^[0-9]+$/', $item_id)) {
$ids[$item_id] = $item_id;
}
}
return array_values($ids);
}
function scm_h($value)
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function scm_is_windows_home(array $home_info)
{
$game_key = isset($home_info['game_key']) ? strtolower((string)$home_info['game_key']) : '';
$cfg_file = isset($home_info['home_cfg_file']) ? strtolower((string)$home_info['home_cfg_file']) : '';
return (strpos($game_key, 'win') !== false) || (strpos($cfg_file, 'win') !== false);
}
function scm_path_is_under_home($home_path, $candidate_path)
{
$home_path = rtrim(clean_path((string)$home_path), '/');
$candidate_path = clean_path((string)$candidate_path);
if ($home_path === '' || $candidate_path === '') {
return false;
}
return strpos($candidate_path.'/', $home_path.'/') === 0;
}
function scm_get_workshop_manifest_path(array $home_info)
{
$home_path = rtrim(clean_path((string)$home_info['home_path']), '/');
$manifest_path = clean_path($home_path . '/gsp_server_content/workshop_manifest.json');
if (!scm_path_is_under_home($home_path, $manifest_path)) {
return false;
}
return $manifest_path;
}
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',
'steam_workshop_app_id',
'steam_workshop_appid',
);
foreach ((array)$candidates as $candidate) {
if (isset($server_xml->$candidate)) {
$value = trim((string)$server_xml->$candidate);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
}
return "";
}
function scm_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)) {
return $xml_path;
}
}
return scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT;
}
function scm_get_configured_workshop_script_path(array $home_info, $server_xml)
{
$key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux';
$nested_key = scm_is_windows_home($home_info) ? 'script_windows' : 'script_linux';
if (isset($server_xml->workshop_support->$nested_key)) {
$xml_path = trim((string)$server_xml->workshop_support->$nested_key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
if (isset($server_xml->$key)) {
$xml_path = trim((string)$server_xml->$key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
return '';
}
function scm_get_bundled_workshop_script_source(array $home_info)
{
$filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT;
return dirname(__FILE__) . '/scripts/workshop/' . $filename;
}
function scm_is_default_workshop_script_name($script_path)
{
$base = basename(trim((string)$script_path));
return in_array($base, array(SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT, SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT), true);
}
function scm_is_legacy_panel_workshop_script_path($script_path)
{
$script_path = trim((string)$script_path);
if ($script_path === '') {
return false;
}
return (strpos($script_path, '/var/www/html/') === 0 && strpos($script_path, '/Panel/modules/addonsmanager/scripts/workshop/') !== false)
|| (strpos($script_path, '/OGP_User_Files/modules/addonsmanager/scripts/') !== false);
}
function scm_get_agent_managed_workshop_script_path(array $home_info)
{
$home_path = rtrim(clean_path((string)$home_info['home_path']), '/');
$filename = 'workshop_job_' . date('Ymd_His') . '_' . mt_rand(1000, 9999) . '.sh';
$remote_path = clean_path($home_path . '/gsp_server_content/jobs/workshop/' . $filename);
if (!scm_path_is_under_home($home_path, $remote_path)) {
return false;
}
return $remote_path;
}
function scm_prepare_workshop_script_for_agent($remote, array $home_info, $server_xml, &$error = '')
{
$error = '';
$configured_path = scm_get_configured_workshop_script_path($home_info, $server_xml);
if ($configured_path !== '' && !scm_is_default_workshop_script_name($configured_path) && !scm_is_legacy_panel_workshop_script_path($configured_path)) {
scm_log_content_install_action(array(
'type' => 'workshop_script_deprecated',
'home_id' => isset($home_info['home_id']) ? (int)$home_info['home_id'] : 0,
'configured_path' => $configured_path,
'message' => 'Configured static Workshop script ignored; Server Content generates a per-job script and runs it through the generic agent exec path.',
));
}
$source_path = scm_get_bundled_workshop_script_source($home_info);
if (!is_file($source_path)) {
$error = 'Panel Workshop job template is missing: ' . $source_path;
return false;
}
$remote_path = scm_get_agent_managed_workshop_script_path($home_info);
if ($remote_path === false) {
$error = 'Unable to resolve an agent-managed workshop script path for this server.';
return false;
}
$script_body = @file_get_contents($source_path);
if ($script_body === false || $script_body === '') {
$error = 'Failed to read Panel Workshop job template: ' . $source_path;
return false;
}
$remote_dir = dirname($remote_path);
$remote->exec("mkdir -p " . escapeshellarg($remote_dir));
if ((int)$remote->remote_writefile($remote_path, $script_body) !== 1) {
$error = 'Failed to sync workshop script to agent host.';
return false;
}
$remote->exec("chmod 755 " . escapeshellarg($remote_path) . " >/dev/null 2>&1 || true");
return $remote_path;
}
function scm_get_default_workshop_target_template($install_strategy = '')
{
$install_strategy = strtolower(trim((string)$install_strategy));
if (in_array($install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true)) {
return SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT;
}
return SCM_WORKSHOP_TARGET_GENERIC_DEFAULT;
}
function scm_get_csrf_token()
{
if (empty($_SESSION['addonsmanager_workshop_csrf'])) {
$_SESSION['addonsmanager_workshop_csrf'] = md5(uniqid((string)mt_rand(), true));
}
return $_SESSION['addonsmanager_workshop_csrf'];
}
function scm_validate_csrf_token($token)
{
if (!isset($_SESSION['addonsmanager_workshop_csrf'])) {
return false;
}
return hash_equals((string)$_SESSION['addonsmanager_workshop_csrf'], (string)$token);
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 2 helpers schema guard, cache mode, manifest, install history
// ─────────────────────────────────────────────────────────────────────────────
/**
* Returns the allowed values for the server_content_cache_mode panel setting.
*
* disabled Always install from the configured source. No scanning,
* no shared cache. (DEFAULT safest choice)
* search_existing_servers Agent may scan other local game-server folders for
* matching cacheable content and copy directly if safe.
* shared_cache Agent may store cacheable content in a shared cache
* folder and reuse it on future installs.
* shared_cache_and_search Both shared_cache and search_existing_servers are
* active simultaneously.
*
* Security note: only content explicitly marked is_cacheable=1 on the addon
* record may ever be shared or cached. Private configs, user-edited files,
* saves, databases, logs, and credentials must never be included.
*
* @return array<string,string> key => human-readable label
*/
function scm_get_valid_cache_modes()
{
return array(
'disabled' => 'Disabled (always install from source)',
'search_existing_servers' => 'Search existing servers (copy from local installs)',
'shared_cache' => 'Shared cache (store and reuse cached copies)',
'shared_cache_and_search' => 'Shared cache + search existing servers',
);
}
/**
* Reads the current server_content_cache_mode panel setting.
* Returns 'disabled' if not set.
*
* @param object $db Panel DB handle
* @return string One of the scm_get_valid_cache_modes() keys
*/
function scm_get_cache_mode($db)
{
$valid = scm_get_valid_cache_modes();
$value = '';
if (method_exists($db, 'getSetting')) {
$value = (string)$db->getSetting('server_content_cache_mode');
}
return array_key_exists($value, $valid) ? $value : 'disabled';
}
/**
* Returns allowed install_method values and their display labels.
*
* @return array<string,string>
*/
function scm_get_install_methods()
{
return array(
'download_zip' => 'Downloadable Mod',
'steam_workshop' => 'Steam Workshop Mods',
'config_edit' => 'Configuration Package',
'post_script' => 'Scripted Installer',
);
}
function scm_get_install_method_help_text()
{
return array(
'download_zip' => 'Download and extract a ZIP, RAR, or archive file.',
'steam_workshop' => 'Configure how users may install Steam Workshop items for this game. Users enter the actual Workshop IDs from their server page.',
'config_edit' => 'Install configuration files, profiles, or templates.',
'post_script' => 'Run a custom scripted installation process.',
);
}
function scm_get_install_method_required_fields()
{
return array(
'download_zip' => array('url'),
'steam_workshop' => array(), // No required fields; users provide Workshop IDs on their server page
'post_script' => array('post_script'),
'config_edit' => array('path', 'config_edit_rule'),
);
}
function scm_get_install_method_validation_errors()
{
return array(
'download_zip' => 'Please enter a download URL.',
'steam_workshop' => 'Please configure Workshop App ID or ensure the game XML provides one.',
'config_edit' => 'Please enter the config target and edit action.',
'post_script' => 'Please enter the installer script/action.',
);
}
function scm_get_install_method_default($value = '')
{
$value = trim((string)$value);
if ($value === 'download_file') {
$value = 'download_zip';
}
if ($value === 'create_folder') {
$value = 'config_edit';
}
$methods = scm_get_install_methods();
return isset($methods[$value]) ? $value : 'download_zip';
}
function scm_get_install_payload_keys()
{
return array(
'url',
'path',
'workshop_item_id',
'workshop_app_id',
'target_path_template',
'optional_folder_name',
'post_script',
'config_edit_rule',
'launch_param_additions',
);
}
function scm_collect_install_payload(array $defaults = array(), array $request = array(), array $override_keys = array())
{
$payload = array();
foreach (scm_get_install_payload_keys() as $key) {
$payload[$key] = isset($defaults[$key]) ? trim((string)$defaults[$key]) : '';
}
foreach ($override_keys as $key) {
if (array_key_exists($key, $request)) {
$payload[$key] = trim((string)$request[$key]);
}
}
return $payload;
}
function scm_validate_numeric_content_value($value, $error_message, &$message, $allow_blank = false)
{
$value = trim((string)$value);
if ($value === '') {
if ($allow_blank) {
$message = '';
return true;
}
$message = $error_message;
return false;
}
if (!preg_match('/^[0-9]+$/', $value)) {
$message = $error_message;
return false;
}
$message = '';
return true;
}
function scm_validate_download_content(array $payload, &$message = '')
{
$url = isset($payload['url']) ? trim((string)$payload['url']) : '';
if ($url === '') {
$message = 'Please enter a download URL.';
return false;
}
$message = '';
return true;
}
function scm_validate_workshop_content(array $payload, &$message = '')
{
// workshop_item_id is NOT required for admin content templates.
// Users supply Workshop IDs on their server page (workshop_content.php).
if (!scm_validate_numeric_content_value(isset($payload['workshop_app_id']) ? $payload['workshop_app_id'] : '', 'Workshop App ID must be numeric.', $message, true)) {
return false;
}
$folder_name = isset($payload['optional_folder_name']) ? trim((string)$payload['optional_folder_name']) : '';
if ($folder_name !== '' && (strpos($folder_name, '..') !== false || preg_match('/[\\\\\\/]/', $folder_name))) {
$message = 'Optional folder name must be a single folder name.';
return false;
}
$message = '';
return true;
}
function scm_validate_workshop_user_ids($raw_ids, &$message = '')
{
$invalid = array();
$ids = scm_parse_workshop_ids($raw_ids, $invalid);
if (!empty($invalid)) {
$message = 'Invalid Workshop item entries. Use a numeric Workshop ID or a Steam Workshop URL: ' . implode(', ', $invalid);
return false;
}
if (empty($ids)) {
$message = 'Enter at least one Steam Workshop ID or Workshop URL.';
return false;
}
$message = '';
return true;
}
function scm_validate_scripted_installer(array $payload, &$message = '')
{
$script = isset($payload['post_script']) ? trim((string)$payload['post_script']) : '';
if ($script === '') {
$message = 'Please enter the installer script/action.';
return false;
}
$message = '';
return true;
}
function scm_validate_configuration_package(array $payload, &$message = '')
{
$path = isset($payload['path']) ? trim((string)$payload['path']) : '';
$rule = isset($payload['config_edit_rule']) ? trim((string)$payload['config_edit_rule']) : '';
if ($path === '' || $rule === '') {
$message = 'Please enter the config target and edit action.';
return false;
}
$message = '';
return true;
}
function scm_validate_install_method_payload($install_method, array $payload, &$message = '')
{
$install_method = scm_get_install_method_default($install_method);
if (!isset(scm_get_install_method_required_fields()[$install_method])) {
$message = 'Invalid install/content type selected.';
return false;
}
if ($install_method === 'download_zip') {
return scm_validate_download_content($payload, $message);
}
if ($install_method === 'steam_workshop') {
return scm_validate_workshop_content($payload, $message);
}
if ($install_method === 'post_script') {
return scm_validate_scripted_installer($payload, $message);
}
if ($install_method === 'config_edit') {
return scm_validate_configuration_package($payload, $message);
}
$message = '';
return true;
}
function scm_build_workshop_runtime_context($db, array $home_info, $server_xml, array $payload, &$message = '')
{
// workshop_item_id is now optional in admin templates; validate only the
// numeric format constraints (workshop_app_id, optional_folder_name).
if (!scm_validate_workshop_content($payload, $message)) {
return false;
}
$workshop_item_id = trim((string)(isset($payload['workshop_item_id']) ? $payload['workshop_item_id'] : ''));
$target_path_template = trim((string)(isset($payload['target_path_template']) ? $payload['target_path_template'] : ''));
$optional_folder_name = trim((string)(isset($payload['optional_folder_name']) ? $payload['optional_folder_name'] : ''));
$workshop_app_id_override = trim((string)(isset($payload['workshop_app_id']) ? $payload['workshop_app_id'] : ''));
$install_strategy = isset($payload['install_strategy']) ? trim((string)$payload['install_strategy']) : '';
$fallback_profile = function_exists('sw_get_profile_for_home') ? sw_get_profile_for_home($db, (int)$home_info['home_id']) : false;
$resolved = function_exists('steam_workshop_install_item_to_home')
? steam_workshop_install_item_to_home($db, $home_info, $workshop_item_id, $target_path_template, array(
'optional_folder_name' => $optional_folder_name,
'workshop_app_id' => $workshop_app_id_override,
))
: array('ok' => false);
if (!empty($resolved['ok'])) {
$profile = (isset($resolved['profile']) && is_array($resolved['profile'])) ? $resolved['profile'] : array();
$message = '';
return array(
'workshop_item_id' => $workshop_item_id,
'workshop_app_id' => isset($resolved['workshop_app_id']) ? (string)$resolved['workshop_app_id'] : '',
'steam_app_id' => isset($resolved['steam_app_id']) ? (string)$resolved['steam_app_id'] : '',
'folder_name' => isset($resolved['folder_name']) ? (string)$resolved['folder_name'] : ($optional_folder_name !== '' ? $optional_folder_name : '@' . $workshop_item_id),
'target_path_template' => isset($resolved['target_path_template']) ? (string)$resolved['target_path_template'] : $target_path_template,
'target_path_resolved' => isset($resolved['target_path_resolved']) ? (string)$resolved['target_path_resolved'] : '',
'server_root' => rtrim((string)$home_info['home_path'], '/'),
'steamcmd_path' => isset($profile['steamcmd_path']) ? trim((string)$profile['steamcmd_path']) : '',
'workshop_download_dir' => (isset($profile['workshop_download_dir_template']) && trim((string)$profile['workshop_download_dir_template']) !== '')
? sw_apply_template((string)$profile['workshop_download_dir_template'], (array)$resolved['vars'])
: '',
);
}
$fallback_workshop_app_id = $workshop_app_id_override;
if ($fallback_workshop_app_id === '' && is_array($fallback_profile) && !empty($fallback_profile['workshop_app_id'])) {
$fallback_workshop_app_id = (string)$fallback_profile['workshop_app_id'];
}
if ($fallback_workshop_app_id === '') {
$fallback_workshop_app_id = scm_extract_workshop_app_id($server_xml);
}
$steam_app_id = (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : scm_extract_workshop_steam_app_id($server_xml);
$folder_name = ($optional_folder_name !== '') ? $optional_folder_name : '@' . $workshop_item_id;
$effective_template = $target_path_template;
if ($effective_template === '') {
if (is_array($fallback_profile) && !empty($fallback_profile['install_path_template'])) {
$effective_template = (string)$fallback_profile['install_path_template'];
} else {
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$effective_template = $xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy);
}
}
$placeholder_map = scm_build_placeholder_map($home_info, array('exe_location' => isset($server_xml->exe_location) ? (string)$server_xml->exe_location : ''), array(
'WORKSHOP_ID' => $workshop_item_id,
'WORKSHOP_APP_ID' => $fallback_workshop_app_id,
'STEAM_APP_ID' => $steam_app_id,
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
));
$message = '';
return array(
'workshop_item_id' => $workshop_item_id,
'workshop_app_id' => $fallback_workshop_app_id,
'steam_app_id' => $steam_app_id,
'folder_name' => $folder_name,
'target_path_template' => $effective_template,
'target_path_resolved' => scm_apply_placeholders($effective_template, $placeholder_map),
'server_root' => rtrim((string)$home_info['home_path'], '/'),
'steamcmd_path' => (is_array($fallback_profile) && !empty($fallback_profile['steamcmd_path'])) ? (string)$fallback_profile['steamcmd_path'] : '',
'workshop_download_dir' => '',
);
}
function scm_detect_workshop_install_strategy(array $home_info, $server_xml, array $template = array())
{
if (!empty($template['install_strategy'])) {
$strategy = trim((string)$template['install_strategy']);
if (preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
if (isset($server_xml->workshop_support->install_strategy)) {
$strategy = trim((string)$server_xml->workshop_support->install_strategy);
if ($strategy !== '' && preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
foreach (array('workshop_install_strategy', 'install_strategy') as $tag) {
if (isset($server_xml->$tag)) {
$strategy = trim((string)$server_xml->$tag);
if ($strategy !== '' && preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
}
$game_key = strtolower((string)(isset($home_info['game_key']) ? $home_info['game_key'] : ''));
$cfg_file = strtolower((string)(isset($home_info['home_cfg_file']) ? $home_info['home_cfg_file'] : ''));
$name = strtolower((string)(isset($home_info['game_name']) ? $home_info['game_name'] : ''));
$haystack = $game_key . ' ' . $cfg_file . ' ' . $name;
if (strpos($haystack, 'dayz') !== false) {
return 'dayz_mod_folder';
}
if (strpos($haystack, 'arma') !== false) {
return 'arma_mod_folder';
}
return 'copy_to_mod_folder';
}
function scm_workshop_should_copy_keys($server_xml, $install_strategy)
{
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)) {
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);
$server_root = rtrim(clean_path((string)(isset($home_info['home_path']) ? $home_info['home_path'] : '')), '/');
$game_root = $server_root;
if (!empty($server_context['exe_location'])) {
$exe_location = clean_path((string)$server_context['exe_location']);
$exe_dir = dirname($exe_location);
if ($exe_dir !== '.' && $exe_dir !== '/') {
$game_root = clean_path($server_root . '/' . ltrim($exe_dir, '/'));
}
}
$map = array(
'{HOME_ID}' => (string)$home_id,
'{SERVER_ROOT}' => $server_root,
'{GAME_ROOT}' => $game_root,
'{WORKSHOP_ID}' => '',
'{WORKSHOP_APP_ID}' => '',
'{STEAM_APP_ID}' => '',
'{FOLDER_NAME}' => '',
'{MOD_FOLDER}' => '',
);
foreach ($overrides as $key => $value) {
$token = '{' . strtoupper(trim((string)$key, '{}')) . '}';
$map[$token] = (string)$value;
}
return $map;
}
function scm_apply_placeholders($template, array $placeholder_map)
{
$template = (string)$template;
if ($template === '') {
return '';
}
return str_replace(array_keys($placeholder_map), array_values($placeholder_map), $template);
}
function scm_content_logs_dir()
{
return dirname(__FILE__) . '/logs';
}
function scm_content_log_file()
{
return scm_content_logs_dir() . '/content_install.log';
}
function scm_log_content_install_action(array $context)
{
$dir = scm_content_logs_dir();
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$context['logged_at'] = date('Y-m-d H:i:s');
$line = json_encode($context);
if ($line === false) {
$line = '{"logged_at":"' . date('Y-m-d H:i:s') . '","error":"json_encode_failed"}';
}
@error_log($line . PHP_EOL, 3, scm_content_log_file());
return true;
}
/**
* Idempotently ensures the Phase 2 schema is present.
* Called from pages that use manifest / history data so that existing
* installs that have not yet run the module updater are covered.
*
* @param object $db Panel DB handle
* @return bool
*/
function scm_ensure_phase2_schema($db)
{
static $phase2_checked = false;
if ($phase2_checked) {
return true;
}
$phase2_checked = true;
$prefix = OGP_DB_PREFIX;
// ── Extend addons table ───────────────────────────────────────────────────
$new_columns = array(
'install_method' => "VARCHAR(32) NOT NULL DEFAULT 'download_zip'",
'content_version' => "VARCHAR(64) NULL",
'requires_stop' => "TINYINT(1) NOT NULL DEFAULT 1",
'backup_before_install' => "TINYINT(1) NOT NULL DEFAULT 1",
'restart_after_install' => "TINYINT(1) NOT NULL DEFAULT 0",
'is_cacheable' => "TINYINT(1) NOT NULL DEFAULT 0",
'description' => "TEXT NULL",
'workshop_item_id' => "VARCHAR(64) NULL",
'workshop_app_id' => "VARCHAR(32) NULL",
'target_path_template' => "VARCHAR(255) NULL",
'optional_folder_name' => "VARCHAR(255) NULL",
'config_edit_rule' => "TEXT NULL",
'launch_param_additions'=> "VARCHAR(255) NULL",
'allow_user_workshop_ids' => "TINYINT(1) NOT NULL DEFAULT 1",
'max_workshop_ids' => "INT NULL",
'required_workshop_ids' => "TEXT NULL",
'blocked_workshop_ids' => "TEXT NULL",
);
foreach ($new_columns as $col => $definition) {
$escaped_col = $db->realEscapeSingle($col);
$escaped_table = $db->realEscapeSingle($prefix . 'addons');
$check = $db->resultQuery(
"SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$escaped_table}'
AND COLUMN_NAME = '{$escaped_col}'"
);
if (empty($check)) {
$db->query("ALTER TABLE `{$prefix}addons` ADD COLUMN `{$col}` {$definition}");
}
}
// ── Per-server manifest ───────────────────────────────────────────────────
$db->query(
"CREATE TABLE IF NOT EXISTS `{$prefix}server_content_manifest` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`home_id` INT NOT NULL,
`addon_id` INT NOT NULL,
`install_method` VARCHAR(32) NOT NULL DEFAULT 'download_zip',
`content_version` VARCHAR(64) NULL,
`install_state` VARCHAR(32) NOT NULL DEFAULT 'installed',
`checksum_sha256` VARCHAR(64) NULL,
`source_url` VARCHAR(255) NULL,
`installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`installed_by` INT NULL,
`updated_at` DATETIME NULL,
`notes` TEXT NULL,
UNIQUE KEY `uniq_home_addon` (`home_id`, `addon_id`),
KEY `idx_home_id` (`home_id`),
KEY `idx_addon_id` (`addon_id`),
KEY `idx_install_state` (`install_state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
// ── Install history ───────────────────────────────────────────────────────
$db->query(
"CREATE TABLE IF NOT EXISTS `{$prefix}server_content_install_history` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`home_id` INT NOT NULL,
`addon_id` INT NOT NULL,
`installed_by` INT NULL,
`started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`completed_at` DATETIME NULL,
`install_state` VARCHAR(32) NOT NULL DEFAULT 'started',
`install_method` VARCHAR(32) NULL,
`content_version` VARCHAR(64) NULL,
`source_url` VARCHAR(255) NULL,
`cache_mode_used` VARCHAR(32) NULL,
`result_code` INT NULL,
`log_output` MEDIUMTEXT NULL,
KEY `idx_home_id` (`home_id`),
KEY `idx_addon_id` (`addon_id`),
KEY `idx_started_at` (`started_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return true;
}
/**
* Returns all manifest rows for a given server home.
*
* @param object $db
* @param int $home_id
* @return array
*/
function scm_get_manifest_rows($db, $home_id)
{
$home_id = (int)$home_id;
if ($home_id <= 0 || !scm_ensure_phase2_schema($db)) {
return array();
}
$rows = $db->resultQuery(
"SELECT m.*, a.name AS addon_name, a.addon_type, a.install_method AS addon_install_method
FROM `".OGP_DB_PREFIX."server_content_manifest` m
LEFT JOIN `".OGP_DB_PREFIX."addons` a ON a.addon_id = m.addon_id
WHERE m.home_id = {$home_id}
ORDER BY m.installed_at DESC"
);
return is_array($rows) ? $rows : array();
}
/**
* Creates a new install history row and returns its insert ID.
* Returns 0 on failure.
*
* @param object $db
* @param int $home_id
* @param int $addon_id
* @param int $user_id
* @param string $source_url
* @param string $content_version
* @param string $install_method
* @param string $cache_mode_used
* @return int history row ID, or 0 on failure
*/
function scm_record_install_start($db, $home_id, $addon_id, $user_id, $source_url = '', $content_version = '', $install_method = 'download_zip', $cache_mode_used = 'disabled')
{
$home_id = (int)$home_id;
$addon_id = (int)$addon_id;
$user_id = (int)$user_id;
$source_url = $db->realEscapeSingle((string)$source_url);
$content_version = $db->realEscapeSingle((string)$content_version);
$install_method = $db->realEscapeSingle((string)$install_method);
$cache_mode_used = $db->realEscapeSingle((string)$cache_mode_used);
if (!scm_ensure_phase2_schema($db)) {
return 0;
}
$id = $db->resultInsertId(
'server_content_install_history',
array(
'home_id' => $home_id,
'addon_id' => $addon_id,
'installed_by' => $user_id,
'install_state' => 'started',
'install_method' => $install_method,
'content_version' => $content_version,
'source_url' => $source_url,
'cache_mode_used' => $cache_mode_used,
)
);
return is_numeric($id) ? (int)$id : 0;
}
/**
* Updates an existing install history row with the final result.
*
* @param object $db
* @param int $history_id
* @param string $state 'installed' | 'failed' | 'cancelled'
* @param int $result_code Exit code (0 = success)
* @param string $log_output Script/download log snippet
* @return bool
*/
function scm_record_install_done($db, $history_id, $state = 'installed', $result_code = 0, $log_output = '')
{
$history_id = (int)$history_id;
$state = $db->realEscapeSingle((string)$state);
$result_code = (int)$result_code;
$log_output = $db->realEscapeSingle((string)$log_output);
if ($history_id <= 0) {
return false;
}
return (bool)$db->query(
"UPDATE `".OGP_DB_PREFIX."server_content_install_history`
SET install_state = '{$state}',
result_code = {$result_code},
log_output = '{$log_output}',
completed_at = NOW()
WHERE id = {$history_id}"
);
}
/**
* Inserts or updates a server_content_manifest row for a successful install.
*
* @param object $db
* @param int $home_id
* @param int $addon_id
* @param array $fields Optional overrides: install_method, content_version,
* install_state, source_url, checksum_sha256, installed_by
* @return bool
*/
function scm_upsert_manifest($db, $home_id, $addon_id, array $fields = array())
{
$home_id = (int)$home_id;
$addon_id = (int)$addon_id;
if ($home_id <= 0 || $addon_id <= 0 || !scm_ensure_phase2_schema($db)) {
return false;
}
$install_method = $db->realEscapeSingle((string)(isset($fields['install_method']) ? $fields['install_method'] : 'download_zip'));
$content_version = $db->realEscapeSingle((string)(isset($fields['content_version']) ? $fields['content_version'] : ''));
$install_state = $db->realEscapeSingle((string)(isset($fields['install_state']) ? $fields['install_state'] : 'installed'));
$source_url = $db->realEscapeSingle((string)(isset($fields['source_url']) ? $fields['source_url'] : ''));
$checksum = $db->realEscapeSingle((string)(isset($fields['checksum_sha256']) ? $fields['checksum_sha256'] : ''));
$installed_by = isset($fields['installed_by']) ? (int)$fields['installed_by'] : 'NULL';
if ($installed_by !== 'NULL' && $installed_by <= 0) {
$installed_by = 'NULL';
}
return (bool)$db->query(
"INSERT INTO `".OGP_DB_PREFIX."server_content_manifest`
(`home_id`,`addon_id`,`install_method`,`content_version`,`install_state`,`source_url`,`checksum_sha256`,`installed_by`,`installed_at`,`updated_at`)
VALUES
({$home_id},{$addon_id},'{$install_method}','{$content_version}','{$install_state}','{$source_url}','{$checksum}',{$installed_by},NOW(),NOW())
ON DUPLICATE KEY UPDATE
install_method = VALUES(install_method),
content_version = VALUES(content_version),
install_state = VALUES(install_state),
source_url = VALUES(source_url),
checksum_sha256 = VALUES(checksum_sha256),
installed_at = NOW(),
updated_at = NOW()"
);
}