children()) > 0)) { $isFlat = false; break; } } if ($isFlat) { return implode(',', array_map(function ($item) { return (string)$item; }, $value)); } return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * Return an HTML-safe string suitable for display (echo) in the editor. * Always wraps the result in htmlspecialchars. */ function gsp_value_to_display_string($value): string { return htmlspecialchars(gsp_normalize_config_value($value), ENT_QUOTES, 'UTF-8'); } /** * Return an HTML-safe string suitable for use as an HTML form field value. * Identical to gsp_value_to_display_string — kept as a distinct function so * callers can signal intent and future formatting rules can differ. */ function gsp_value_to_editable_string($value): string { return htmlspecialchars(gsp_normalize_config_value($value), ENT_QUOTES, 'UTF-8'); } function config_games_normalize_path($path) { $clean = preg_replace('/[^A-Za-z0-9_\\[\\]\\/\\-]/', '', (string)$path); return ltrim($clean, '/'); } function config_games_normalize_newlines($text) { return preg_replace("/\\r\\n?/", "\\n", (string)$text); } function config_games_next_form_key(): string { static $counter = 0; $counter++; return 'node_' . $counter; } // Schema-defined element order and required/optional flags for game_config root. // Source: modules/config_games/schema_server_config.xml (server_config_type sequence). function config_games_schema_order(): array { return [ 'game_key' => true, 'protocol' => false, 'lgsl_query_name' => false, 'gameq_query_name' => false, 'installer' => false, 'game_name' => true, 'server_exec_name' => true, 'query_port' => false, 'cli_template' => false, 'cli_params' => false, 'reserve_ports' => false, 'cli_allow_chars' => false, 'maps_location' => false, 'map_list' => false, 'console_log' => false, 'exe_location' => false, 'max_user_amount' => false, 'control_protocol' => false, 'control_protocol_type' => false, 'mods' => true, 'replace_texts' => false, 'server_params' => false, 'custom_fields' => false, 'list_players_command' => false, 'player_info_regex' => false, 'player_info' => false, 'player_commands' => false, 'pre_install' => false, 'post_install' => false, 'pre_start' => false, 'post_start' => false, 'environment_variables' => false, 'lock_files' => false, 'configuration_files' => false, ]; } /** * Validate an XML file against the game config schema. * Returns an empty array on success, or an array of error strings on failure. */ function config_games_validate_xml_file(string $config_file): array { if (!file_exists($config_file) || !is_readable($config_file)) { return ['Configuration file not found or unreadable: ' . htmlspecialchars($config_file, ENT_QUOTES, 'UTF-8')]; } $prev = libxml_use_internal_errors(true); libxml_clear_errors(); $dom = new DOMDocument(); if ($dom->load($config_file) === false) { $errors = array_map(function ($e) { return trim($e->message) . ' (line ' . $e->line . ')'; }, libxml_get_errors()); libxml_clear_errors(); libxml_use_internal_errors($prev); return $errors ?: ['XML is not well-formed.']; } if ($dom->schemaValidate(XML_SCHEMA) !== true) { $errors = array_map(function ($e) { return trim($e->message) . ' (line ' . $e->line . ')'; }, libxml_get_errors()); libxml_clear_errors(); libxml_use_internal_errors($prev); return $errors ?: ['XML failed schema validation.']; } libxml_clear_errors(); libxml_use_internal_errors($prev); return []; } /** * Script-like element names whose text content is shell/batch code. * These nodes should be stored as CDATA sections so that characters such as * '<', '>', '&', etc. survive round-trips through the XML parser unchanged. */ function config_games_script_node_names(): array { return ['pre_install', 'post_install', 'pre_start', 'post_start', 'precmd', 'postcmd']; } /** * Auto-sanitize raw XML text: for every script-like element whose text content * contains a bare '<' outside an existing CDATA block, wrap that content in a * CDATA section so the file becomes well-formed. Non-script elements are left * untouched. * * Assumptions / limitations: * - Script elements are treated as leaf nodes (no child elements expected). * Nested XML tags inside a script block are not supported and the regex * will not handle them correctly. * - The detection of an already-present CDATA section relies on the opening * tag being immediately followed by ']*)?>(?!\s*/si', function ($m) use ($tag) { $attrs = $m[1]; $content = $m[2]; // Only wrap if the content contains a raw '<' character. XML // entities such as < do not contain a literal '<' so they // are not matched here and do not trigger CDATA wrapping. if (strpos($content, '<') === false) { return $m[0]; } return '<' . $tag . $attrs . '>'; }, $xml ); } return $xml; } function config_games_print_editor_css() { static $printed = false; if ($printed) { return; } $printed = true; echo << .xml-editor-wrapper{margin:20px 0;padding:12px;background:#111;border:1px solid #222;border-radius:8px} .xml-node{border:1px solid #333;border-radius:6px;padding:12px;margin-bottom:10px;background:#181818} .xml-node--required{border-left:3px solid #1c6dd0} .xml-node__header{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid #2a2a2a;padding-bottom:6px;margin-bottom:8px} .xml-node__title{font-weight:600;color:#f5f5f5} .xml-node__title--required::after{content:" *";color:#e06c75;font-size:0.8rem} .xml-node__path{font-size:0.85rem;color:#989898} .xml-node__badge{font-size:0.72rem;padding:2px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:0.05em;margin-left:6px} .xml-node__badge--required{background:#1c3a6d;color:#7eb3f0} .xml-node__badge--optional{background:#2a2a2a;color:#888} .xml-node__body label{font-size:0.85rem;color:#bbb;display:block;margin-bottom:4px} .xml-node__body input[type="text"], .xml-node__body textarea, .xml-node__body select{width:100%;padding:8px;border:1px solid #3a3a3a;border-radius:4px;background:#101010;color:#fff;font-family:monospace} .xml-node__body textarea{min-height:120px} .xml-node__attributes{margin-top:8px} .xml-node__attributes .attr-row{display:flex;gap:8px;align-items:center;margin-bottom:6px} .xml-node__attributes .attr-row input[type="text"]{flex:1} .xml-children{margin-top:10px;border-left:2px solid #2a2a2a;padding-left:12px} .xml-actions{display:flex;justify-content:flex-end;margin-top:16px;padding:8px 18px 0} .xml-node__actions{display:flex;gap:8px;align-items:center} .xml-node__apply{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:6px 12px;border-radius:4px;cursor:pointer} .xml-node__apply:hover{background:#1f7aec} .xml-global-save{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:10px 28px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;cursor:pointer;transition:background 0.2s ease,transform 0.2s ease;box-shadow:0 2px 6px rgba(0,0,0,0.35)} .xml-global-save:hover{background:#1f7aec;transform:translateY(-1px)} .xml-global-save--top{float:right;margin:0 18px 12px 0} .xml-hint{font-size:0.85rem;color:#999;margin-top:4px} .xml-validation-errors{background:#2d0f0f;border:1px solid #8b1c1c;border-radius:6px;padding:12px 16px;margin-bottom:14px;color:#f88} .xml-validation-errors ul{margin:6px 0 0 16px;padding:0} .xml-raw-toggle{margin:8px 0 4px;color:#7eb3f0;cursor:pointer;font-size:0.9rem;text-decoration:underline;background:none;border:none;padding:0} .xml-raw-section{margin-top:10px;display:none} .xml-raw-section textarea{width:100%;min-height:300px;font-family:monospace;font-size:0.85rem;background:#0c0c0c;color:#eee;border:1px solid #3a3a3a;border-radius:4px;padding:8px} .xml-raw-warning{background:#2d2200;border:1px solid #7a5a00;border-radius:4px;padding:8px 12px;color:#f0c050;font-size:0.85rem;margin-bottom:6px} .xml-section-header{margin:20px 0 4px;font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:0.1em;border-bottom:1px solid #2a2a2a;padding-bottom:4px} .xml-node__desc{font-size:0.82rem;color:#aaa;background:#0e0e0e;border-left:3px solid #2a4a7a;padding:6px 10px;margin:6px 0 8px;border-radius:0 4px 4px 0} .xml-node__options{margin:4px 0 4px 12px;padding:0;list-style:disc inside} .xml-node__options li{margin-bottom:2px} .xml-node__options code{color:#7eb3f0;background:rgba(30,100,200,0.12);padding:1px 4px;border-radius:3px} .xml-node__example{display:block;margin-top:4px;color:#888} .xml-node__example code{color:#a0d0a0;background:rgba(30,150,50,0.1);padding:1px 4px;border-radius:3px} .xml-jump-link{display:inline-block;margin-bottom:12px;padding:6px 14px;background:#1c6dd0;color:#fff;border-radius:4px;text-decoration:none;font-size:0.9rem} .xml-jump-link:hover{background:#1f7aec;text-decoration:none} CSS; } function config_games_render_node(SimpleXMLElement $node, array $ancestors, array &$counters, int $depth = 0, ?bool $isRequired = null) { $schemaOrder = config_games_schema_order(); $name = $node->getName(); $pathKey = implode('/', $ancestors) === '' ? $name : implode('/', $ancestors) . '/' . $name; $counters[$pathKey] = ($counters[$pathKey] ?? 0) + 1; $index = $counters[$pathKey]; $pathParts = array_merge($ancestors, ["{$name}[{$index}]"]); $rawPath = implode('/', $pathParts); $path = config_games_normalize_path($rawPath); $hasChildren = count($node->children()) > 0; $value = (string)$node; $safeLabel = htmlspecialchars($name, ENT_QUOTES, 'UTF-8'); $safePath = htmlspecialchars($path, ENT_QUOTES, 'UTF-8'); $nodeKey = config_games_next_form_key(); $safeNodeKey = htmlspecialchars($nodeKey, ENT_QUOTES, 'UTF-8'); $displayPath = htmlspecialchars(str_replace('[', '[', $rawPath), ENT_QUOTES, 'UTF-8'); $isScript = in_array(strtolower($name), ['pre_install','post_install','precmd','postcmd','cli_template']); // Determine required status: use passed value, fall back to schema lookup for top-level nodes if ($isRequired === null) { $isRequired = $depth === 1 && array_key_exists($name, $schemaOrder) ? $schemaOrder[$name] : false; } $nodeClass = 'xml-node depth-' . $depth . ($isRequired ? ' xml-node--required' : ''); $badge = $isRequired ? "required" : "optional"; // Look up per-tag description from the descriptions helper. $tagDescriptions = config_games_tag_descriptions(); $tagDesc = $tagDescriptions[$name] ?? null; $html = "
"; $actionId = 'node_action_' . substr(md5($safePath . $index), 0, 8); $html .= "
{$safeLabel}{$badge}
{$displayPath}
"; $html .= "
"; $html .= ""; $html .= "
"; if ($tagDesc !== null) { $safeDesc = htmlspecialchars($tagDesc['desc'], ENT_QUOTES, 'UTF-8'); $html .= "
{$safeDesc}"; if (!empty($tagDesc['options'])) { $html .= "
    "; foreach ($tagDesc['options'] as $optVal => $optLabel) { $safeOptVal = htmlspecialchars((string)$optVal, ENT_QUOTES, 'UTF-8'); $safeOptLabel = htmlspecialchars($optLabel, ENT_QUOTES, 'UTF-8'); $html .= "
  • {$safeOptVal} – {$safeOptLabel}
  • "; } $html .= "
"; } if (!empty($tagDesc['example'])) { $safeExample = htmlspecialchars($tagDesc['example'], ENT_QUOTES, 'UTF-8'); $html .= "Example: {$safeExample}"; } $html .= "
"; } $html .= "
"; $html .= ""; $html .= ""; if (!$hasChildren || $isScript) { $safeValue = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); if ($isScript || strlen($value) > 120) { $html .= ""; } else { $html .= ""; } } elseif (trim($value) !== '') { $safeValue = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); $html .= ""; $html .= "

This element contains nested tags; clearing the text does not remove children.

"; } $attributes = $node->attributes(); if ($attributes && count($attributes) > 0) { $html .= "
Attributes"; foreach ($attributes as $attrName => $attrValue) { $attrSafe = htmlspecialchars($attrName, ENT_QUOTES, 'UTF-8'); $valSafe = gsp_value_to_editable_string($attrValue); $html .= "
{$attrSafe}
"; } $html .= "
"; $html .= "
"; } else { $html .= "
"; } if ($hasChildren) { $html .= "
"; foreach ($node->children() as $child) { $html .= config_games_render_node($child, array_merge($ancestors, ["{$name}[{$index}]"]), $counters, $depth + 1); } $html .= "
"; } $html .= "
"; return $html; } function config_games_render_editor(SimpleXMLElement $xml) { config_games_print_editor_css(); $schemaOrder = config_games_schema_order(); $rootName = $xml->getName(); $html = "
"; $counters = []; // Sort top-level children by schema order; unknown elements follow at the end. $children = iterator_to_array($xml->children(), false); usort($children, function ($a, $b) use ($schemaOrder) { $nameA = $a->getName(); $nameB = $b->getName(); $orderKeys = array_keys($schemaOrder); $posA = ($idx = array_search($nameA, $orderKeys)) !== false ? $idx : PHP_INT_MAX; $posB = ($idx = array_search($nameB, $orderKeys)) !== false ? $idx : PHP_INT_MAX; return $posA <=> $posB; }); $lastSection = null; foreach ($children as $child) { $cName = $child->getName(); $isRequired = $schemaOrder[$cName] ?? null; // Print section dividers between required and optional groups $section = ($isRequired === true) ? 'required' : (($isRequired === false) ? 'optional' : 'custom'); if ($section !== $lastSection) { if ($section === 'required') { $html .= "
★ Required Fields
"; } elseif ($section === 'optional') { $html .= "
Optional Fields
"; } else { $html .= "
Custom / Unknown Fields
"; } $lastSection = $section; } $html .= config_games_render_node($child, [$rootName], $counters, 0, $isRequired); } $html .= "
"; return $html; } /** * Save XML from structured form nodes payload. * Validates against the schema before writing. * Returns true on success, or an array of error strings on failure. */ function config_games_save_xml($db, $home_cfg_id, array $nodesPayload) { $cfg_info = $db->getGameCfg($home_cfg_id); if ($cfg_info === FALSE) { return ['Configuration record not found in database.']; } $config_file = SERVER_CONFIG_LOCATION . $cfg_info['home_cfg_file']; if (!file_exists($config_file) || !is_readable($config_file)) { return ['Configuration file not found or not readable: ' . htmlspecialchars($config_file, ENT_QUOTES, 'UTF-8')]; } $nodes = []; foreach ((array)$nodesPayload as $key => $data) { $rawPath = isset($data['path']) ? (string)$data['path'] : (string)$key; $cleanPath = config_games_normalize_path($rawPath); if ($cleanPath === '') { continue; } $data['path'] = $cleanPath; $nodes[$cleanPath] = $data; } if (empty($nodes)) { return ['No node data was submitted.']; } $dom = new DOMDocument(); $dom->preserveWhiteSpace = false; $dom->formatOutput = true; if (@$dom->load($config_file) === false) { return ['The configuration file could not be parsed as XML. It may be malformed.']; } // Keep a backup of the original content so we can restore on validation failure. $backup = file_get_contents($config_file); $xpath = new DOMXPath($dom); uksort($nodes, function ($a, $b) { return substr_count($b, '/') <=> substr_count($a, '/'); }); foreach ((array)$nodes as $path => $nodeData) { $query = '/' . $path; $nodeList = @$xpath->query($query); if (!$nodeList || $nodeList->length === 0) { continue; } $domNode = $nodeList->item(0); $action = $nodeData['action'] ?? 'keep'; if ($action === 'remove') { if ($domNode->parentNode) { $domNode->parentNode->removeChild($domNode); } continue; } $hasChildren = !empty($nodeData['has_children']); $nodeName = strtolower(($slashPos = strrpos($path, '/')) !== false ? substr($path, $slashPos + 1) : $path); $isScriptNode = in_array($nodeName, config_games_script_node_names(), true); if (array_key_exists('value', (array)$nodeData)) { $normalizedValue = config_games_normalize_newlines($nodeData['value']); while ($domNode->firstChild) { $domNode->removeChild($domNode->firstChild); } if ($normalizedValue !== '') { if ($isScriptNode) { $domNode->appendChild($dom->createCDATASection($normalizedValue)); } else { $domNode->appendChild($dom->createTextNode($normalizedValue)); } } } elseif (!$hasChildren) { while ($domNode->firstChild) { $domNode->removeChild($domNode->firstChild); } } if (isset($nodeData['attributes']) && is_array($nodeData['attributes'])) { foreach ((array)$nodeData['attributes'] as $attrName => $attrValue) { $attrNameClean = preg_replace('/[^A-Za-z0-9_\\-:]/', '', (string)$attrName); if ($attrNameClean === '') { continue; } $attrValue = trim((string)$attrValue); if ($attrValue === '') { $domNode->removeAttribute($attrNameClean); } else { $domNode->setAttribute($attrNameClean, $attrValue); } } } if (isset($nodeData['new_attribute']['name']) && $nodeData['new_attribute']['name'] !== '') { $newName = preg_replace('/[^A-Za-z0-9_\\-:]/', '', (string)$nodeData['new_attribute']['name']); $newValue = (string)($nodeData['new_attribute']['value'] ?? ''); if ($newName !== '' && $newValue !== '') { $domNode->setAttribute($newName, $newValue); } } } if ($dom->save($config_file) === false) { // Restore backup on write failure if (isset($backup)) { file_put_contents($config_file, $backup); } return ['Failed to write the configuration file. Check file permissions.']; } // Validate the saved file against the schema. $errors = config_games_validate_xml_file($config_file); if (!empty($errors)) { // Restore original on schema failure if (isset($backup)) { file_put_contents($config_file, $backup); } return $errors; } $savedContents = @file_get_contents($config_file); if ($savedContents !== false) { $normalizedContents = config_games_normalize_newlines($savedContents); if ($normalizedContents !== $savedContents) { file_put_contents($config_file, $normalizedContents); } } $config = read_server_config($config_file); if ($config !== FALSE) { $db->addGameCfg($config); } return true; } function exec_ogp_module() { global $db,$view; $game_cfgs = $db->getGameCfgs(); echo "

".get_lang('game_config_setup')."

\n

".get_lang_f("modify_configs_info",SERVER_CONFIG_LOCATION)."

\n
\n

\n

".get_lang('note').": ".get_lang('config_reset_warning')."

\n

\n
\n"; if ( isset($_REQUEST['reconfig']) ) { // Remove any old config files that may have been renamed or removed by developers // Function is defined in helpers.php (add entries to array there) removeOldGameConfigs(); $files = glob(SERVER_CONFIG_LOCATION."*.xml"); if ( empty($files) ) { print_failure(get_lang_f("no_configs_found",SERVER_CONFIG_LOCATION)); return; } /// \todo remove the clear_old hack when the update on duplicate is completed to database. $clear_old = FALSE; if ( isset( $_REQUEST['clear_old']) && $_REQUEST['clear_old'] === 'yes' ) { echo "

".get_lang('resetting_configs').":

"; $clear_old = TRUE; } else { echo "

".get_lang('updating_configs').":

"; } $oldStructure = $db->getCurrentHomeConfigMods(); $db->clearGameCfgs($clear_old); foreach ((array)$files as $config_file) { $config = read_server_config($config_file); if ( empty($config) ) { print_failure(get_lang_f("error_when_handling_file",$config_file)); continue; } echo "

".get_lang_f("updating_config_from_file",$config_file)."

"; if ( !$db->addGameCfg($config) ) { print_failure(get_lang_f("error_while_adding_cfg_to_db",$config_file)); continue; } } // Update and remove invalid old game mod ids if($clear_old){ $db->updateOGPGameModsWithNewIDs($oldStructure); } print_success(get_lang('configs_updated_ok')); } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_xml']) && isset($_POST['home_cfg_id'])) { $edit_id = (int)$_POST['home_cfg_id']; // Raw XML save path if (isset($_POST['raw_xml_content'])) { $cfg_info = $db->getGameCfg($edit_id); if ($cfg_info !== FALSE) { $config_file = SERVER_CONFIG_LOCATION . $cfg_info['home_cfg_file']; $raw_content = $_POST['raw_xml_content']; // Apply best-effort auto-fix: wrap bare '<' chars in script blocks with CDATA. $sanitized_content = config_games_sanitize_xml_scripts($raw_content); // Write to a temp file for validation $tmp = tempnam(sys_get_temp_dir(), 'gsp_xml_'); file_put_contents($tmp, $sanitized_content); $xmlErrors = config_games_validate_xml_file($tmp); @unlink($tmp); if (!empty($xmlErrors)) { echo "
⚠ XML validation failed — file was NOT saved:
"; } else { if (file_put_contents($config_file, $sanitized_content) !== false) { print_success(get_lang('configs_updated_ok')); $config = read_server_config($config_file); if ($config !== FALSE) { $db->addGameCfg($config); } } else { print_failure('Failed to write configuration file. Check permissions.'); } } } else { print_failure('Configuration record not found.'); } } else { $nodesPayload = isset($_POST['nodes']) && is_array($_POST['nodes']) ? $_POST['nodes'] : []; $result = config_games_save_xml($db, $edit_id, $nodesPayload); if ($result === true) { print_success(get_lang('configs_updated_ok')); } else { $errors = is_array($result) ? $result : ['Failed to save XML configuration.']; echo "
⚠ XML validation failed — file was NOT saved:
"; } } $_GET['home_cfg_id'] = $edit_id; } $game_cfgs = $db->getGameCfgs(); echo "\n \n \n \n \n \n
\n \n
\n"; if ( isset($_GET['home_cfg_id']) ) { echo "

↓ Jump to XML Editor

"; $home_cfg_id = trim($_GET['home_cfg_id']); $cfg_info = $db->getGameCfg($home_cfg_id); if($cfg_info !== FALSE) { $config_file = SERVER_CONFIG_LOCATION.$cfg_info['home_cfg_file']; if ( preg_match( "/_win/", $cfg_info['game_key'] ) ) $os = "(Windows)"; if (preg_match( "/_linux/", $cfg_info['game_key'] ) ) $os = "(Linux)"; if (preg_match( "/64/", $cfg_info['game_key'] ) ) $arch = "(64bit)"; else $arch = ""; if( isset($_GET['delete']) ) { if( $db->delGameCfgAndMods($home_cfg_id) === FALSE ) { print_failure(get_lang_f('failed_to_delete_config_from_db',$cfg_info['game_name'])); $view->refresh('?m=config_games&home_cfg_id='.$home_cfg_id,3); } elseif( unlink($config_file) === FALSE ) { print_failure(get_lang_f('failed_removing_file',$config_file)); $view->refresh('?m=config_games&home_cfg_id='.$home_cfg_id,3); } else { print_success(get_lang_f('removed_game_cfg_from_disk_and_datbase',$cfg_info['game_name']." $os $arch")); $view->refresh('?m=config_games',3); } } else { echo "".get_lang_f('delete_game_config_for',$cfg_info['game_name']." $os $arch")."
"; $xml = @simplexml_load_file($config_file); // Also show any schema validation errors on the current file (informational, before editing) $existingErrors = config_games_validate_xml_file($config_file); if (!empty($existingErrors)) { echo "
⚠ This file currently fails schema validation:
"; } if ($xml === false) { print_failure(get_lang_f("error_when_handling_file",$config_file)); } else { $raw_xml_content = htmlspecialchars(file_get_contents($config_file), ENT_QUOTES, 'UTF-8'); echo "
"; echo "
"; echo ""; echo ""; echo "
"; echo config_games_render_editor($xml); echo "
"; echo "

★ = required field. Use the action dropdown to remove entire sections. Attribute values left blank will be removed. Script sections such as post_install are fully editable. Changes are validated against the schema before saving.

"; // Raw XML editor echo "
"; echo "

Raw XML Editor

"; echo "
Warning: Saving raw XML bypasses the guided editor. The file will be validated against the schema before saving. Invalid XML will be rejected.
"; echo ""; echo "
"; echo ""; echo "
"; echo "
"; echo "
"; echo "
"; // #xml-editor-section } } } } if(isset($_GET['xml_config_creator'])) { echo ""; } else { echo "
"; } } ?>