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([])); } } }