diff --git a/modules/steam_workshop/lib/SteamWorkshopService.php b/modules/steam_workshop/lib/SteamWorkshopService.php index 30e04823..5c803d3e 100644 --- a/modules/steam_workshop/lib/SteamWorkshopService.php +++ b/modules/steam_workshop/lib/SteamWorkshopService.php @@ -3,684 +3,677 @@ declare(strict_types=1); class SteamWorkshopService { - private const MIN_INTERVAL = 15; - private const MAX_INTERVAL = 360; - - private OGPDatabase $db; - private string $configDir; - private string $adapterDir; - private string $adapterMapFile; - private string $gameAdapterDir; - - public function __construct(OGPDatabase $db) - { - $this->db = $db; - $this->configDir = __DIR__ . '/../data/configs'; - $this->adapterDir = __DIR__ . '/GameAdapters'; - $this->adapterMapFile = __DIR__ . '/../data/game_adapter_map.json'; - $this->gameAdapterDir = __DIR__ . '/../data/game_adapters'; - - if (!is_dir($this->configDir)) { - mkdir($this->configDir, 0775, true); - } - - if (!is_dir($this->gameAdapterDir)) { - mkdir($this->gameAdapterDir, 0775, true); - } - - $this->ensureDataFiles(); - } - - /** - * Fetch all homes visible to the given user. - * - * @return array> - */ - public function listHomesForUser(int $userId, bool $isAdmin): array - { - $accessType = $isAdmin ? 'admin' : 'user_and_group'; - $homes = $this->db->getHomesFor($accessType, $userId); - - if ($homes === false || $homes === null) { - return []; - } - - return array_values($homes); - } - - /** - * Retrieve a single home, ensuring the user is allowed to see it. - */ - public function getHome(int $homeId, int $userId, bool $isAdmin): ?array - { - $home = $isAdmin - ? $this->db->getGameHome($homeId) - : $this->db->getUserGameHome($userId, $homeId); - - return is_array($home) ? $home : null; - } - - /** - * @return array{ - * workshop_enabled: bool, - * adapter_key: string, - * update_interval_minutes: int, - * staging_dir: string, - * install_strategy: string, - * on_update_action: string, - * post_install_script: string, - * workshop_items: array>, - * raw_definition: string, - * last_saved_at: int|null - * } - */ - public function loadConfig(int $homeId): array - { - $path = $this->getConfigPath($homeId); - - if (!is_file($path)) { - return $this->defaultConfig(); - } - - $xml = @simplexml_load_file($path); - if ($xml === false) { - return $this->defaultConfig(); - } - - $config = $this->defaultConfig(); - $config['workshop_enabled'] = ((string)($xml->enabled ?? 'false')) === 'true'; - $config['adapter_key'] = (string)($xml->adapter['key'] ?? $config['adapter_key']); - $config['update_interval_minutes'] = $this->sanitizeInterval((int)($xml->updateInterval ?? $config['update_interval_minutes'])); - $config['staging_dir'] = trim((string)($xml->stagingDir ?? '')); - $config['install_strategy'] = (string)($xml->installStrategy ?? $config['install_strategy']); - $config['on_update_action'] = (string)($xml->onUpdateAction ?? $config['on_update_action']); - $config['post_install_script'] = trim((string)($xml->postInstallScript ?? '')); - $config['raw_definition'] = (string)($xml->rawDefinition ?? ''); - $config['last_saved_at'] = isset($xml->timestamps->savedAt) - ? (int)$xml->timestamps->savedAt - : null; - - $mods = []; - if (isset($xml->mods)) { - foreach ($xml->mods->mod as $mod) { - $mods[] = [ - 'id' => (string)$mod['id'], - 'label' => (string)$mod['label'], - 'enabled' => ((string)$mod['enabled']) !== 'false', - 'source' => (string)($mod['source'] ?? 'manual'), - ]; - } - } - - $config['workshop_items'] = $mods; - - return $config; - } - - public function saveConfig(int $homeId, array $config): void - { - $path = $this->getConfigPath($homeId); - $config = $this->normalizeConfig($config); - $doc = new DOMDocument('1.0', 'UTF-8'); - $doc->formatOutput = true; - - $root = $doc->createElement('workshop'); - $doc->appendChild($root); - - $root->appendChild($doc->createElement('enabled', $config['workshop_enabled'] ? 'true' : 'false')); - - $adapterNode = $doc->createElement('adapter'); - $adapterNode->setAttribute('key', $config['adapter_key']); - $root->appendChild($adapterNode); - - $root->appendChild($doc->createElement('updateInterval', (string)$config['update_interval_minutes'])); - $root->appendChild($doc->createElement('stagingDir', $config['staging_dir'])); - $root->appendChild($doc->createElement('installStrategy', $config['install_strategy'])); - $root->appendChild($doc->createElement('onUpdateAction', $config['on_update_action'])); - $root->appendChild($doc->createElement('postInstallScript', $config['post_install_script'])); - $root->appendChild($doc->createElement('rawDefinition', $config['raw_definition'])); - - $modsNode = $doc->createElement('mods'); - foreach ($config['workshop_items'] as $item) { - $mod = $doc->createElement('mod'); - $mod->setAttribute('id', (string)$item['id']); - $mod->setAttribute('label', (string)$item['label']); - $mod->setAttribute('enabled', !empty($item['enabled']) ? 'true' : 'false'); - $mod->setAttribute('source', (string)($item['source'] ?? 'manual')); - $modsNode->appendChild($mod); - } - $root->appendChild($modsNode); - - $timestampsNode = $doc->createElement('timestamps'); - $timestampsNode->appendChild($doc->createElement('savedAt', (string)time())); - $root->appendChild($timestampsNode); - - $doc->save($path); - } - if ($gameKey === '') { - throw new RuntimeException('Game key is required.'); - } - - $normalized = $this->normalizeAdapterData($gameKey, $data); - if ($normalized['steam_app_id'] === '') { - throw new RuntimeException('Steam App ID is required.'); - } - if ($normalized['mods_dir'] === '') { - throw new RuntimeException('Mods directory is required.'); - } - - $doc = new DOMDocument('1.0', 'UTF-8'); - $doc->formatOutput = true; - - $root = $doc->createElement('adapter'); - $root->setAttribute('key', $gameKey); - $root->setAttribute('name', $normalized['name']); - $doc->appendChild($root); - - $root->appendChild($doc->createElement('steamAppId', $normalized['steam_app_id'])); - $root->appendChild($doc->createElement('modsDir', $normalized['mods_dir'])); - if ($normalized['keys_dir'] !== '') { - $root->appendChild($doc->createElement('keysDir', $normalized['keys_dir'])); - } - $root->appendChild($doc->createElement('supportsHotReload', $normalized['supports_hot_reload'] ? 'true' : 'false')); - - $activationNode = $doc->createElement('activation'); - $templateNode = $doc->createElement('template'); - if ($normalized['activation_template'] !== '') { - $templateNode->appendChild($doc->createCDATASection($normalized['activation_template'])); - } - $activationNode->appendChild($templateNode); - $root->appendChild($activationNode); - - if ($normalized['notes'] !== '') { - $root->appendChild($doc->createElement('notes', $normalized['notes'])); - } - - $path = $this->getGameAdapterPath($gameKey); - $doc->save($path); - $lines = preg_split('/\r\n|\r|\n/', $raw); - foreach ($lines as $line) { - $line = trim($line); - if ($line === '') { - continue; - if ($gameKey === '') { - return false; - } - - $path = $this->getGameAdapterPath($gameKey); - if (!is_file($path)) { - return false; - } - - return unlink($path); - } - - $items[] = [ - 'id' => $id, - 'label' => $label, - 'enabled' => true, - 'source' => 'manual', - ]; - } - - return $items; - } - - /** - * Build a SteamCMD command array for a single workshop item. - */ - public function buildSteamCmdArgs(array $config, string $workshopId, ?string $login = null): array - { - $loginUser = $login !== null && $login !== '' ? $login : 'anonymous'; - $adapter = $this->getAdapterByKey($config['adapter_key'] ?? ''); - $appId = $adapter['steam_app_id'] ?? ($config['steam_app_id'] ?? ''); - - return [ - '+login', $loginUser, - '+workshop_download_item', $appId, - $workshopId, - 'validate', - ]; - } - - public function getAdapterOptions(): array - { - $options = []; - foreach ($this->loadAdapters() as $adapter) { - $options[$adapter['key']] = $adapter['name']; - } - - if (empty($options)) { - $options['dayz'] = 'DayZ (fallback)'; - } - - return $options; - } - - /** - * Load adapter metadata for UI and validation. - * - * @return array> - */ - public function loadAdapters(): array - { - $result = []; - $schema = $this->adapterDir . '/schema.xsd'; - $useSchema = is_file($schema); - $previousLibxml = libxml_use_internal_errors(true); - - foreach (glob($this->adapterDir . '/*.xml') as $file) { - if (substr($file, -4) !== '.xml' || basename($file) === 'schema.xsd') { - continue; - } - - $parsed = $this->parseAdapterFile($file, $schema, $useSchema); - if ($parsed !== null) { - $parsed['origin'] = 'shared'; - $result[] = $parsed; - } - } - - foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { - if (substr($file, -4) !== '.xml') { - continue; - } - - $gameKey = basename($file, '.xml'); - $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); - if ($parsed !== null) { - $parsed['origin'] = 'custom'; - $result[] = $parsed; - } - } - - libxml_use_internal_errors($previousLibxml); - - return array_values(array_filter($result, static function (array $adapter): bool { - return $adapter['key'] !== ''; - })); - } - - public function getAdapterByKey(string $key): array - { - foreach ($this->loadAdapters() as $adapter) { - if ($adapter['key'] === $key) { - return $adapter; - } - } - - return []; - } - - /** - * Return adapter key chosen for the given game key, or null if unmapped. - */ - public function getAdapterKeyForGame(string $gameKey): ?string - { - $gameKey = trim($gameKey); - if ($gameKey === '') { - return null; - } - - $map = $this->getAdapterMappings(); - if (isset($map[$gameKey])) { - return $map[$gameKey]; - } - - if ($this->gameAdapterExists($gameKey)) { - return $gameKey; - } - - return null; - } - - /** - * Persist adapter mappings (game_key => adapter_key). - */ - public function saveAdapterMappings(array $mappings): void - { - $sanitized = []; - $options = $this->getAdapterOptions(); - foreach ($mappings as $gameKey => $adapterKey) { - $gameKey = trim((string)$gameKey); - $adapterKey = $this->sanitizeAdapterKey((string)$adapterKey); - if ($gameKey === '' || !isset($options[$adapterKey])) { - continue; - } - $sanitized[$gameKey] = $adapterKey; - } - - file_put_contents($this->adapterMapFile, json_encode($sanitized, JSON_PRETTY_PRINT)); - } - - public function upsertAdapterMapping(string $gameKey, string $adapterKey): void - { - $gameKey = $this->sanitizeGameKey($gameKey); - $adapterKey = $this->sanitizeAdapterKey($adapterKey); - if ($gameKey === '' || $adapterKey === '') { - return; - } - - $map = $this->getAdapterMappings(); - $map[$gameKey] = $adapterKey; - file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); - } - - public function removeAdapterMapping(string $gameKey, ?string $adapterKey = null): void - { - $gameKey = $this->sanitizeGameKey($gameKey); - if ($gameKey === '') { - return; - } - - $map = $this->getAdapterMappings(); - if ($adapterKey === null || (isset($map[$gameKey]) && $map[$gameKey] === $adapterKey)) { - unset($map[$gameKey]); - file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); - } - } - - /** - * @return array - */ - public function getAdapterMappings(): array - { - if (!is_file($this->adapterMapFile)) { - return []; - } - - $raw = file_get_contents($this->adapterMapFile); - $decoded = json_decode((string)$raw, true); - if (!is_array($decoded)) { - return []; - } - - $result = []; - foreach ($decoded as $gameKey => $adapterKey) { - if (!is_string($gameKey) || !is_string($adapterKey)) { - continue; - } - $result[$gameKey] = $adapterKey; - } - - return $result; - } - - /** - * Return metadata for every custom adapter stored on disk. - * - * @return array> - */ - public function listGameAdapters(): array - { - $adapters = []; - $schema = $this->adapterDir . '/schema.xsd'; - $useSchema = is_file($schema); - foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { - $gameKey = basename($file, '.xml'); - $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); - if ($parsed !== null) { - $parsed['origin'] = 'custom'; - $parsed['game_key'] = $gameKey; - $adapters[] = $parsed; - } - } - - return $adapters; - } - - public function gameAdapterExists(string $gameKey): bool - { - $gameKey = $this->sanitizeGameKey($gameKey); - if ($gameKey === '') { - return false; - } - - return is_file($this->getGameAdapterPath($gameKey)); - } - - public function getGameAdapter(string $gameKey): ?array - { - $gameKey = $this->sanitizeGameKey($gameKey); - if ($gameKey === '') { - return null; - } - - $path = $this->getGameAdapterPath($gameKey); - if (!is_file($path)) { - return null; - } - - return $this->parseAdapterFile($path, $this->adapterDir . '/schema.xsd', is_file($this->adapterDir . '/schema.xsd'), $gameKey); - } - - public function getGameAdapterUpdatedAt(string $gameKey): ?int - { - $path = $this->getGameAdapterPath($gameKey); - if (!is_file($path)) { - return null; - } - - $mtime = filemtime($path); - return $mtime === false ? null : $mtime; - } - - public function getAdapterFormData(string $gameKey, ?array $overrides = null): array - { - $gameKey = $this->sanitizeGameKey($gameKey); - $defaults = [ - 'game_key' => $gameKey, - 'name' => $gameKey, - 'steam_app_id' => '', - 'mods_dir' => '', - 'keys_dir' => '', - 'supports_hot_reload' => false, - 'activation_template' => '', - 'notes' => '', - 'exists' => false, - ]; - - $current = $this->getGameAdapter($gameKey); - if ($current !== null) { - $defaults = array_merge($defaults, [ - 'name' => $current['name'] ?? $gameKey, - 'steam_app_id' => $current['steam_app_id'] ?? '', - 'mods_dir' => $current['mods_dir'] ?? '', - 'keys_dir' => $current['keys_dir'] ?? '', - 'supports_hot_reload' => !empty($current['supports_hot_reload']), - 'activation_template' => $current['activation_template'] ?? '', - 'notes' => $current['notes'] ?? '', - 'exists' => true, - ]); - } - - if ($overrides !== null) { - $defaults = array_merge($defaults, $overrides); - } - - return $defaults; - } - - /** - * Discover available game keys from server config XMLs. - * - * @return array - */ - public function listAvailableGameKeys(): array - { - $keys = []; - $configDir = defined('SERVER_CONFIG_LOCATION') ? SERVER_CONFIG_LOCATION : __DIR__ . '/../../config_games/server_configs'; - foreach (glob($configDir . '/*.xml') as $file) { - $xml = @simplexml_load_file($file); - if ($xml === false) { - continue; - } - if (isset($xml->game_key)) { - $keys[] = trim((string)$xml->game_key); - } - } - - $keys = array_filter(array_unique($keys)); - sort($keys); - return array_values($keys); - } - - private function sanitizeInterval(?int $minutes): int - { - if ($minutes === null || $minutes <= 0) { - $minutes = 60; - } - - return max(self::MIN_INTERVAL, min(self::MAX_INTERVAL, $minutes)); - } - - private function sanitizeAdapterKey(string $key): string - { - $key = strtolower(trim($key)); - if ($key === '') { - return 'dayz'; - } - - $adapters = $this->getAdapterOptions(); - if (array_key_exists($key, $adapters)) { - return $key; - } - - $adapterKeys = array_keys($adapters); - return $adapterKeys[0] ?? 'dayz'; - } - - private function sanitizeInstallStrategy(string $strategy): string - { - $valid = ['copy', 'symlink', 'staging']; - return in_array($strategy, $valid, true) ? $strategy : 'copy'; - } - - private function sanitizeUpdateAction(string $action): string - { - $valid = ['queue_for_restart', 'hot_reload_if_supported']; - return in_array($action, $valid, true) ? $action : 'queue_for_restart'; - } - - private function normalizeConfig(array $config): array - { - $config = array_merge($this->defaultConfig(), $config); - $config['update_interval_minutes'] = $this->sanitizeInterval((int)$config['update_interval_minutes']); - $config['adapter_key'] = $this->sanitizeAdapterKey((string)$config['adapter_key']); - $config['install_strategy'] = $this->sanitizeInstallStrategy((string)$config['install_strategy']); - $config['on_update_action'] = $this->sanitizeUpdateAction((string)$config['on_update_action']); - $config['workshop_items'] = array_map(static function (array $item): array { - $item['id'] = preg_replace('/[^0-9]/', '', (string)($item['id'] ?? '')); - $item['label'] = trim((string)($item['label'] ?? '')); - $item['enabled'] = !empty($item['enabled']); - $item['source'] = $item['source'] ?? 'manual'; - return $item; - }, $config['workshop_items']); - - $config['workshop_items'] = array_values(array_filter($config['workshop_items'], static function (array $item): bool { - return $item['id'] !== ''; - })); - - return $config; - } - - private function getGameAdapterPath(string $gameKey): string - { - return sprintf('%s/%s.xml', $this->gameAdapterDir, $gameKey); - } - - private function sanitizeGameKey(string $gameKey): string - { - $gameKey = strtolower(trim($gameKey)); - return preg_replace('/[^a-z0-9_\-.]/', '', $gameKey); - } - - private function normalizeAdapterData(string $gameKey, array $data): array - { - $name = trim((string)($data['name'] ?? '')); - return [ - 'name' => $name !== '' ? $name : $gameKey, - 'steam_app_id' => trim((string)($data['steam_app_id'] ?? '')), - 'mods_dir' => trim((string)($data['mods_dir'] ?? '')), - 'keys_dir' => trim((string)($data['keys_dir'] ?? '')), - 'supports_hot_reload' => !empty($data['supports_hot_reload']), - 'activation_template' => trim((string)($data['activation_template'] ?? '')), - 'notes' => trim((string)($data['notes'] ?? '')), - ]; - } - - private function parseAdapterFile(string $file, string $schemaPath, bool $useSchema, ?string $forcedKey = null): ?array - { - $previous = libxml_use_internal_errors(true); - $doc = new DOMDocument(); - if (!$doc->load($file)) { - libxml_use_internal_errors($previous); - return null; - } - - if ($useSchema && is_file($schemaPath) && !$doc->schemaValidate($schemaPath)) { - libxml_clear_errors(); - libxml_use_internal_errors($previous); - return null; - } - - $adapter = simplexml_import_dom($doc); - if ($adapter === false) { - libxml_use_internal_errors($previous); - return null; - } - - $key = $forcedKey ?? (string)($adapter['key'] ?? ''); - if ($key === '') { - return null; - } - - $result = [ - 'key' => $key, - 'name' => (string)($adapter['name'] ?? $key), - 'steam_app_id' => (string)($adapter->steamAppId ?? ''), - 'mods_dir' => (string)($adapter->modsDir ?? ''), - 'keys_dir' => isset($adapter->keysDir) ? (string)$adapter->keysDir : '', - 'supports_hot_reload' => filter_var((string)($adapter->supportsHotReload ?? 'false'), FILTER_VALIDATE_BOOLEAN), - 'activation_template' => (string)($adapter->activation->template ?? ''), - 'notes' => (string)($adapter->notes ?? ''), - ]; - - libxml_use_internal_errors($previous); - - return $result; - } - - private function getConfigPath(int $homeId): string - { - return sprintf('%s/%d.xml', $this->configDir, $homeId); - } - - private function defaultConfig(): array - { - return [ - 'workshop_enabled' => false, - 'adapter_key' => 'dayz', - 'update_interval_minutes' => 60, - 'staging_dir' => '', - 'install_strategy' => 'copy', - 'on_update_action' => 'queue_for_restart', - 'post_install_script' => '', - 'workshop_items' => [], - 'raw_definition' => '', - 'last_saved_at' => null, - ]; - } - - private function ensureDataFiles(): void - { - $dir = dirname($this->adapterMapFile); - if (!is_dir($dir)) { - mkdir($dir, 0775, true); - } - - if (!is_file($this->adapterMapFile)) { - file_put_contents($this->adapterMapFile, json_encode([])); - } - } + private const MIN_INTERVAL = 15; + private const MAX_INTERVAL = 360; + + private OGPDatabase $db; + private string $configDir; + private string $adapterDir; + private string $adapterMapFile; + private string $gameAdapterDir; + + public function __construct(OGPDatabase $db) + { + $this->db = $db; + $this->configDir = __DIR__ . '/../data/configs'; + $this->adapterDir = __DIR__ . '/GameAdapters'; + $this->adapterMapFile = __DIR__ . '/../data/game_adapter_map.json'; + $this->gameAdapterDir = __DIR__ . '/../data/game_adapters'; + + if (!is_dir($this->configDir)) { + mkdir($this->configDir, 0775, true); + } + + if (!is_dir($this->gameAdapterDir)) { + mkdir($this->gameAdapterDir, 0775, true); + } + + $this->ensureDataFiles(); + } + + /** + * @return array> + */ + public function listHomesForUser(int $userId, bool $isAdmin): array + { + $accessType = $isAdmin ? 'admin' : 'user_and_group'; + $homes = $this->db->getHomesFor($accessType, $userId); + + if ($homes === false || $homes === null) { + return []; + } + + return array_values($homes); + } + + public function getHome(int $homeId, int $userId, bool $isAdmin): ?array + { + $home = $isAdmin + ? $this->db->getGameHome($homeId) + : $this->db->getUserGameHome($userId, $homeId); + + return is_array($home) ? $home : null; + } + + public function loadConfig(int $homeId): array + { + $path = $this->getConfigPath($homeId); + if (!is_file($path)) { + return $this->defaultConfig(); + } + + $xml = @simplexml_load_file($path); + if ($xml === false) { + return $this->defaultConfig(); + } + + $config = $this->defaultConfig(); + $config['workshop_enabled'] = ((string)($xml->enabled ?? 'false')) === 'true'; + $config['adapter_key'] = (string)($xml->adapter['key'] ?? $config['adapter_key']); + $config['update_interval_minutes'] = $this->sanitizeInterval((int)($xml->updateInterval ?? $config['update_interval_minutes'])); + $config['staging_dir'] = trim((string)($xml->stagingDir ?? '')); + $config['install_strategy'] = (string)($xml->installStrategy ?? $config['install_strategy']); + $config['on_update_action'] = (string)($xml->onUpdateAction ?? $config['on_update_action']); + $config['post_install_script'] = trim((string)($xml->postInstallScript ?? '')); + $config['raw_definition'] = (string)($xml->rawDefinition ?? ''); + $config['last_saved_at'] = isset($xml->timestamps->savedAt) ? (int)$xml->timestamps->savedAt : null; + + $mods = []; + if (isset($xml->mods)) { + foreach ($xml->mods->mod as $mod) { + $mods[] = [ + 'id' => (string)$mod['id'], + 'label' => (string)$mod['label'], + 'enabled' => ((string)$mod['enabled']) !== 'false', + 'source' => (string)($mod['source'] ?? 'manual'), + ]; + } + } + + $config['workshop_items'] = $mods; + + return $config; + } + + public function saveConfig(int $homeId, array $config): void + { + $path = $this->getConfigPath($homeId); + $config = $this->normalizeConfig($config); + + $doc = new DOMDocument('1.0', 'UTF-8'); + $doc->formatOutput = true; + + $root = $doc->createElement('workshop'); + $doc->appendChild($root); + + $root->appendChild($doc->createElement('enabled', $config['workshop_enabled'] ? 'true' : 'false')); + + $adapterNode = $doc->createElement('adapter'); + $adapterNode->setAttribute('key', $config['adapter_key']); + $root->appendChild($adapterNode); + + $root->appendChild($doc->createElement('updateInterval', (string)$config['update_interval_minutes'])); + $root->appendChild($doc->createElement('stagingDir', $config['staging_dir'])); + $root->appendChild($doc->createElement('installStrategy', $config['install_strategy'])); + $root->appendChild($doc->createElement('onUpdateAction', $config['on_update_action'])); + $root->appendChild($doc->createElement('postInstallScript', $config['post_install_script'])); + $root->appendChild($doc->createElement('rawDefinition', $config['raw_definition'])); + + $modsNode = $doc->createElement('mods'); + foreach ($config['workshop_items'] as $item) { + $mod = $doc->createElement('mod'); + $mod->setAttribute('id', (string)$item['id']); + $mod->setAttribute('label', (string)$item['label']); + $mod->setAttribute('enabled', !empty($item['enabled']) ? 'true' : 'false'); + $mod->setAttribute('source', (string)($item['source'] ?? 'manual')); + $modsNode->appendChild($mod); + } + $root->appendChild($modsNode); + + $timestampsNode = $doc->createElement('timestamps'); + $timestampsNode->appendChild($doc->createElement('savedAt', (string)time())); + $root->appendChild($timestampsNode); + + $doc->save($path); + } + + public function buildConfigFromRequest(array $payload): array + { + $input = $payload['workshop'] ?? []; + $rawMods = trim((string)($input['raw_items'] ?? '')); + $items = $this->parseWorkshopItems($rawMods); + + return [ + 'workshop_enabled' => isset($input['workshop_enabled']) ? (bool)$input['workshop_enabled'] : false, + 'adapter_key' => $this->sanitizeAdapterKey((string)($input['adapter_key'] ?? 'dayz')), + 'update_interval_minutes' => $this->sanitizeInterval(isset($input['update_interval_minutes']) ? (int)$input['update_interval_minutes'] : null), + 'staging_dir' => trim((string)($input['staging_dir'] ?? '')), + 'install_strategy' => $this->sanitizeInstallStrategy((string)($input['install_strategy'] ?? 'copy')), + 'on_update_action' => $this->sanitizeUpdateAction((string)($input['on_update_action'] ?? 'queue_for_restart')), + 'post_install_script' => trim((string)($input['post_install_script'] ?? '')), + 'workshop_items' => $items, + 'raw_definition' => $rawMods, + ]; + } + + public function parseWorkshopItems(string $raw): array + { + if ($raw === '') { + return []; + } + + $items = []; + $lines = preg_split('/\r\n|\r|\n/', $raw); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $parts = array_map('trim', explode(',', $line, 2)); + $id = preg_replace('/[^0-9]/', '', $parts[0]); + if ($id === '') { + continue; + } + $label = $parts[1] ?? ''; + if ($label === '') { + $label = '@' . $id; + } + + $items[] = [ + 'id' => $id, + 'label' => $label, + 'enabled' => true, + 'source' => 'manual', + ]; + } + + return $items; + } + + public function buildSteamCmdArgs(array $config, string $workshopId, ?string $login = null): array + { + $loginUser = $login !== null && $login !== '' ? $login : 'anonymous'; + $adapter = $this->getAdapterByKey($config['adapter_key'] ?? ''); + $appId = $adapter['steam_app_id'] ?? ($config['steam_app_id'] ?? ''); + + return ['+login', $loginUser, '+workshop_download_item', $appId, $workshopId, 'validate']; + } + + public function getAdapterOptions(): array + { + $options = []; + foreach ($this->loadAdapters() as $adapter) { + $options[$adapter['key']] = $adapter['name']; + } + + if (empty($options)) { + $options['dayz'] = 'DayZ (fallback)'; + } + + return $options; + } + + public function loadAdapters(): array + { + $result = []; + $schema = $this->adapterDir . '/schema.xsd'; + $useSchema = is_file($schema); + + foreach (glob($this->adapterDir . '/*.xml') as $file) { + if (substr($file, -4) !== '.xml' || basename($file) === 'schema.xsd') { + continue; + } + + $parsed = $this->parseAdapterFile($file, $schema, $useSchema); + if ($parsed !== null) { + $parsed['origin'] = 'shared'; + $result[] = $parsed; + } + } + + foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { + if (substr($file, -4) !== '.xml') { + continue; + } + + $gameKey = basename($file, '.xml'); + $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); + if ($parsed !== null) { + $parsed['origin'] = 'custom'; + $result[] = $parsed; + } + } + + return array_values(array_filter($result, static function (array $adapter): bool { + return $adapter['key'] !== ''; + })); + } + + public function getAdapterByKey(string $key): array + { + foreach ($this->loadAdapters() as $adapter) { + if ($adapter['key'] === $key) { + return $adapter; + } + } + + return []; + } + + public function getAdapterKeyForGame(string $gameKey): ?string + { + $gameKey = trim($gameKey); + if ($gameKey === '') { + return null; + } + + $map = $this->getAdapterMappings(); + if (isset($map[$gameKey])) { + return $map[$gameKey]; + } + + if ($this->gameAdapterExists($gameKey)) { + return $gameKey; + } + + return null; + } + + public function saveAdapterMappings(array $mappings): void + { + $sanitized = []; + $options = $this->getAdapterOptions(); + foreach ($mappings as $gameKey => $adapterKey) { + $gameKey = trim((string)$gameKey); + $adapterKey = $this->sanitizeAdapterKey((string)$adapterKey); + if ($gameKey === '' || !isset($options[$adapterKey])) { + continue; + } + $sanitized[$gameKey] = $adapterKey; + } + + file_put_contents($this->adapterMapFile, json_encode($sanitized, JSON_PRETTY_PRINT)); + } + + public function upsertAdapterMapping(string $gameKey, string $adapterKey): void + { + $gameKey = $this->sanitizeGameKey($gameKey); + $adapterKey = $this->sanitizeAdapterKey($adapterKey); + if ($gameKey === '' || $adapterKey === '') { + return; + } + + $map = $this->getAdapterMappings(); + $map[$gameKey] = $adapterKey; + file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); + } + + public function removeAdapterMapping(string $gameKey, ?string $adapterKey = null): void + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return; + } + + $map = $this->getAdapterMappings(); + if ($adapterKey === null || (isset($map[$gameKey]) && $map[$gameKey] === $adapterKey)) { + unset($map[$gameKey]); + file_put_contents($this->adapterMapFile, json_encode($map, JSON_PRETTY_PRINT)); + } + } + + public function getAdapterMappings(): array + { + if (!is_file($this->adapterMapFile)) { + return []; + } + + $raw = file_get_contents($this->adapterMapFile); + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + return []; + } + + $result = []; + foreach ($decoded as $gameKey => $adapterKey) { + if (!is_string($gameKey) || !is_string($adapterKey)) { + continue; + } + $result[$gameKey] = $adapterKey; + } + + return $result; + } + + public function listGameAdapters(): array + { + $adapters = []; + $schema = $this->adapterDir . '/schema.xsd'; + $useSchema = is_file($schema); + foreach (glob($this->gameAdapterDir . '/*.xml') as $file) { + $gameKey = basename($file, '.xml'); + $parsed = $this->parseAdapterFile($file, $schema, $useSchema, $gameKey); + if ($parsed !== null) { + $parsed['origin'] = 'custom'; + $parsed['game_key'] = $gameKey; + $adapters[] = $parsed; + } + } + + return $adapters; + } + + public function gameAdapterExists(string $gameKey): bool + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return false; + } + + return is_file($this->getGameAdapterPath($gameKey)); + } + + public function getGameAdapter(string $gameKey): ?array + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return null; + } + + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return null; + } + + return $this->parseAdapterFile($path, $this->adapterDir . '/schema.xsd', is_file($this->adapterDir . '/schema.xsd'), $gameKey); + } + + public function getGameAdapterUpdatedAt(string $gameKey): ?int + { + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return null; + } + + $mtime = filemtime($path); + return $mtime === false ? null : $mtime; + } + + public function getAdapterFormData(string $gameKey, ?array $overrides = null): array + { + $gameKey = $this->sanitizeGameKey($gameKey); + $defaults = [ + 'game_key' => $gameKey, + 'name' => $gameKey, + 'steam_app_id' => '', + 'mods_dir' => '', + 'keys_dir' => '', + 'supports_hot_reload' => false, + 'activation_template' => '', + 'notes' => '', + 'exists' => false, + ]; + + $current = $this->getGameAdapter($gameKey); + if ($current !== null) { + $defaults = array_merge($defaults, [ + 'name' => $current['name'] ?? $gameKey, + 'steam_app_id' => $current['steam_app_id'] ?? '', + 'mods_dir' => $current['mods_dir'] ?? '', + 'keys_dir' => $current['keys_dir'] ?? '', + 'supports_hot_reload' => !empty($current['supports_hot_reload']), + 'activation_template' => $current['activation_template'] ?? '', + 'notes' => $current['notes'] ?? '', + 'exists' => true, + ]); + } + + if ($overrides !== null) { + $defaults = array_merge($defaults, $overrides); + } + + return $defaults; + } + + public function saveGameAdapter(string $gameKey, array $data): void + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + throw new RuntimeException('Game key is required.'); + } + + $normalized = $this->normalizeAdapterData($gameKey, $data); + if ($normalized['steam_app_id'] === '') { + throw new RuntimeException('Steam App ID is required.'); + } + if ($normalized['mods_dir'] === '') { + throw new RuntimeException('Mods directory is required.'); + } + + $doc = new DOMDocument('1.0', 'UTF-8'); + $doc->formatOutput = true; + + $root = $doc->createElement('adapter'); + $root->setAttribute('key', $gameKey); + $root->setAttribute('name', $normalized['name']); + $doc->appendChild($root); + + $root->appendChild($doc->createElement('steamAppId', $normalized['steam_app_id'])); + $root->appendChild($doc->createElement('modsDir', $normalized['mods_dir'])); + if ($normalized['keys_dir'] !== '') { + $root->appendChild($doc->createElement('keysDir', $normalized['keys_dir'])); + } + $root->appendChild($doc->createElement('supportsHotReload', $normalized['supports_hot_reload'] ? 'true' : 'false')); + + $activationNode = $doc->createElement('activation'); + $templateNode = $doc->createElement('template'); + if ($normalized['activation_template'] !== '') { + $templateNode->appendChild($doc->createCDATASection($normalized['activation_template'])); + } + $activationNode->appendChild($templateNode); + $root->appendChild($activationNode); + + if ($normalized['notes'] !== '') { + $root->appendChild($doc->createElement('notes', $normalized['notes'])); + } + + $path = $this->getGameAdapterPath($gameKey); + $doc->save($path); + } + + public function deleteGameAdapter(string $gameKey): bool + { + $gameKey = $this->sanitizeGameKey($gameKey); + if ($gameKey === '') { + return false; + } + + $path = $this->getGameAdapterPath($gameKey); + if (!is_file($path)) { + return false; + } + + return unlink($path); + } + + public function listAvailableGameKeys(): array + { + $keys = []; + $configDir = defined('SERVER_CONFIG_LOCATION') ? SERVER_CONFIG_LOCATION : __DIR__ . '/../../config_games/server_configs'; + foreach (glob($configDir . '/*.xml') as $file) { + $xml = @simplexml_load_file($file); + if ($xml === false) { + continue; + } + if (isset($xml->game_key)) { + $keys[] = trim((string)$xml->game_key); + } + } + + $keys = array_filter(array_unique($keys)); + sort($keys); + return array_values($keys); + } + + private function sanitizeInterval(?int $minutes): int + { + if ($minutes === null || $minutes <= 0) { + $minutes = 60; + } + + return max(self::MIN_INTERVAL, min(self::MAX_INTERVAL, $minutes)); + } + + private function sanitizeAdapterKey(string $key): string + { + $key = strtolower(trim($key)); + if ($key === '') { + return 'dayz'; + } + + $adapters = $this->getAdapterOptions(); + if (array_key_exists($key, $adapters)) { + return $key; + } + + $adapterKeys = array_keys($adapters); + return $adapterKeys[0] ?? 'dayz'; + } + + private function sanitizeInstallStrategy(string $strategy): string + { + $valid = ['copy', 'symlink', 'staging']; + return in_array($strategy, $valid, true) ? $strategy : 'copy'; + } + + private function sanitizeUpdateAction(string $action): string + { + $valid = ['queue_for_restart', 'hot_reload_if_supported']; + return in_array($action, $valid, true) ? $action : 'queue_for_restart'; + } + + private function normalizeConfig(array $config): array + { + $config = array_merge($this->defaultConfig(), $config); + $config['update_interval_minutes'] = $this->sanitizeInterval((int)$config['update_interval_minutes']); + $config['adapter_key'] = $this->sanitizeAdapterKey((string)$config['adapter_key']); + $config['install_strategy'] = $this->sanitizeInstallStrategy((string)$config['install_strategy']); + $config['on_update_action'] = $this->sanitizeUpdateAction((string)$config['on_update_action']); + $config['workshop_items'] = array_map(static function (array $item): array { + $item['id'] = preg_replace('/[^0-9]/', '', (string)($item['id'] ?? '')); + $item['label'] = trim((string)($item['label'] ?? '')); + $item['enabled'] = !empty($item['enabled']); + $item['source'] = $item['source'] ?? 'manual'; + return $item; + }, $config['workshop_items']); + + $config['workshop_items'] = array_values(array_filter($config['workshop_items'], static function (array $item): bool { + return $item['id'] !== ''; + })); + + return $config; + } + + private function getGameAdapterPath(string $gameKey): string + { + return sprintf('%s/%s.xml', $this->gameAdapterDir, $gameKey); + } + + private function sanitizeGameKey(string $gameKey): string + { + $gameKey = strtolower(trim($gameKey)); + $sanitized = preg_replace('/[^a-z0-9_\-.]/', '', $gameKey); + return is_string($sanitized) ? $sanitized : ''; + } + + private function normalizeAdapterData(string $gameKey, array $data): array + { + $name = trim((string)($data['name'] ?? '')); + return [ + 'name' => $name !== '' ? $name : $gameKey, + 'steam_app_id' => trim((string)($data['steam_app_id'] ?? '')), + 'mods_dir' => trim((string)($data['mods_dir'] ?? '')), + 'keys_dir' => trim((string)($data['keys_dir'] ?? '')), + 'supports_hot_reload' => !empty($data['supports_hot_reload']), + 'activation_template' => trim((string)($data['activation_template'] ?? '')), + 'notes' => trim((string)($data['notes'] ?? '')), + ]; + } + + private function parseAdapterFile(string $file, string $schemaPath, bool $useSchema, ?string $forcedKey = null): ?array + { + $previous = libxml_use_internal_errors(true); + $doc = new DOMDocument(); + if (!$doc->load($file)) { + libxml_use_internal_errors($previous); + return null; + } + + if ($useSchema && is_file($schemaPath) && !$doc->schemaValidate($schemaPath)) { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + return null; + } + + $adapter = simplexml_import_dom($doc); + if ($adapter === false) { + libxml_use_internal_errors($previous); + return null; + } + + $key = $forcedKey ?? (string)($adapter['key'] ?? ''); + if ($key === '') { + libxml_use_internal_errors($previous); + return null; + } + + $result = [ + 'key' => $key, + 'name' => (string)($adapter['name'] ?? $key), + 'steam_app_id' => (string)($adapter->steamAppId ?? ''), + 'mods_dir' => (string)($adapter->modsDir ?? ''), + 'keys_dir' => isset($adapter->keysDir) ? (string)$adapter->keysDir : '', + 'supports_hot_reload' => filter_var((string)($adapter->supportsHotReload ?? 'false'), FILTER_VALIDATE_BOOLEAN), + 'activation_template' => (string)($adapter->activation->template ?? ''), + 'notes' => (string)($adapter->notes ?? ''), + ]; + + libxml_use_internal_errors($previous); + + return $result; + } + + private function getConfigPath(int $homeId): string + { + return sprintf('%s/%d.xml', $this->configDir, $homeId); + } + + private function defaultConfig(): array + { + return [ + 'workshop_enabled' => false, + 'adapter_key' => 'dayz', + 'update_interval_minutes' => 60, + 'staging_dir' => '', + 'install_strategy' => 'copy', + 'on_update_action' => 'queue_for_restart', + 'post_install_script' => '', + 'workshop_items' => [], + 'raw_definition' => '', + 'last_saved_at' => null, + ]; + } + + private function ensureDataFiles(): void + { + $dir = dirname($this->adapterMapFile); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + if (!is_file($this->adapterMapFile)) { + file_put_contents($this->adapterMapFile, json_encode([])); + } + } }