From ed7253d199440b22f8ddff68853219434431e99c Mon Sep 17 00:00:00 2001 From: Frank Harris Date: Sun, 25 Jan 2026 19:39:16 -0600 Subject: [PATCH] trying again on workshop --- CHANGELOG.md | 6 + docs/COPILOT_TODO.md | 1 + .../controllers/SteamWorkshopController.php | 19 +- .../lib/SteamWorkshopService.php | 312 ++++++++++++------ 4 files changed, 228 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 658e054d..6d1a6c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-01-25 +- Replaced the Steam Workshop search backend with the official Steam Web API (QueryFiles) so searches are anonymous, paginated, and no longer depend on fragile HTML scraping. +- Added detailed Steam API failure logging plus structured JSON responses that expose pagination metadata to the UI. +- Introduced a reusable SteamCMD installer helper that downloads Workshop items with anonymous login, falls back to authenticated credentials, and captures all stdout/stderr in per-run log files. +- Documented the new search and install helpers to clarify expected usage from both the panel UI and CLI tooling. + ## 2026-01-17 - Added per-game Steam Workshop adapter management with CRUD UI and automatic mapping helpers. - Added workshop capability helpers and monitor button gating so only supported SteamCMD homes expose the Steam Workshop shortcut. diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md index b1e56f41..9e2c0aee 100644 --- a/docs/COPILOT_TODO.md +++ b/docs/COPILOT_TODO.md @@ -1,2 +1,3 @@ - Auto-detect which server configs actually support Steam Workshop before showing adapter controls. - Allow players/admins to reorder selected Workshop mods in the new picker UI so load order matches game expectations. +- Surface pagination controls in the Workshop picker so users can request additional batches from the new Steam Web API search endpoint. diff --git a/modules/steam_workshop/controllers/SteamWorkshopController.php b/modules/steam_workshop/controllers/SteamWorkshopController.php index ec5c1c77..89a22d97 100644 --- a/modules/steam_workshop/controllers/SteamWorkshopController.php +++ b/modules/steam_workshop/controllers/SteamWorkshopController.php @@ -128,6 +128,8 @@ class SteamWorkshopController header('Content-Type: application/json'); $homeId = isset($_GET['home_id']) ? (int)$_GET['home_id'] : 0; $query = trim((string)($_GET['q'] ?? '')); + $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; + $perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : 12; if ($homeId <= 0) { echo json_encode(['ok' => false, 'error' => $this->lang['error_missing_home'] ?? 'Home ID missing.']); return; @@ -149,13 +151,22 @@ class SteamWorkshopController return; } - $results = $this->service->searchWorkshopItems($gameKey, $query); - if (empty($results)) { - echo json_encode(['ok' => true, 'results' => [], 'empty' => true]); + $payload = $this->service->searchWorkshopItems($gameKey, $query, $perPage, $page); + if ($payload['error'] !== null) { + echo json_encode(['ok' => false, 'error' => $payload['error']]); return; } - echo json_encode(['ok' => true, 'results' => $results]); + $response = [ + 'ok' => true, + 'results' => $payload['results'], + 'pagination' => $payload['pagination'], + ]; + if (empty($payload['results'])) { + $response['empty'] = true; + } + + echo json_encode($response); } private function applyGameAdapterOverride(array $home, array &$config): bool diff --git a/modules/steam_workshop/lib/SteamWorkshopService.php b/modules/steam_workshop/lib/SteamWorkshopService.php index 1923deb8..2d4438a8 100644 --- a/modules/steam_workshop/lib/SteamWorkshopService.php +++ b/modules/steam_workshop/lib/SteamWorkshopService.php @@ -12,6 +12,9 @@ class SteamWorkshopService private string $adapterMapFile; private string $gameAdapterDir; private string $serverConfigDir; + private string $logDir; + private string $apiLogFile; + private string $steamCmdLogDir; public function __construct(OGPDatabase $db) { @@ -23,13 +26,14 @@ class SteamWorkshopService $this->serverConfigDir = defined('SERVER_CONFIG_LOCATION') ? SERVER_CONFIG_LOCATION : __DIR__ . '/../../config_games/server_configs'; + $this->logDir = __DIR__ . '/../logs'; + $this->steamCmdLogDir = $this->logDir . '/steamcmd'; + $this->apiLogFile = $this->logDir . '/steam_api.log'; - if (!is_dir($this->configDir)) { - mkdir($this->configDir, 0775, true); - } - - if (!is_dir($this->gameAdapterDir)) { - mkdir($this->gameAdapterDir, 0775, true); + foreach ([$this->configDir, $this->gameAdapterDir, $this->logDir, $this->steamCmdLogDir] as $dir) { + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } } $this->ensureDataFiles(); @@ -210,6 +214,44 @@ class SteamWorkshopService return ['+login', $loginUser, '+workshop_download_item', $appId, $workshopId, 'validate']; } + /** + * Example usage: + * $service->installWorkshopItem('/opt/steamcmd/steamcmd.sh', '221100', '1234567890'); + */ + public function installWorkshopItem(string $steamCmdPath, string $appId, string $workshopId, ?string $username = null, ?string $password = null, ?string $logFile = null): array + { + $logPath = $logFile ?? sprintf('%s/%s-%s-%s.log', $this->steamCmdLogDir, $appId, $workshopId, date('Ymd_His')); + $appId = trim($appId); + $workshopId = preg_replace('/[^0-9]/', '', $workshopId); + + if ($steamCmdPath === '' || !is_file($steamCmdPath)) { + $message = sprintf('SteamCMD binary not found at %s', $steamCmdPath); + $this->appendLog($logPath, $message); + return ['success' => false, 'error' => $message, 'log_file' => $logPath, 'attempts' => []]; + } + + $attempts = []; + $logins = [['user' => 'anonymous', 'password' => null]]; + if ($username !== null && $username !== '') { + $logins[] = ['user' => $username, 'password' => $password]; + } + + foreach ($logins as $credentials) { + $this->appendLog($logPath, sprintf('SteamCMD download start app=%s workshop=%s login=%s', $appId, $workshopId, $credentials['user'])); + $result = $this->runSteamCmdDownload($steamCmdPath, $appId, $workshopId, $credentials['user'], $credentials['password']); + $this->appendSteamCmdOutput($logPath, $result['output']); + $this->appendLog($logPath, sprintf('SteamCMD exit code %d for login %s', $result['exit_code'], $credentials['user'])); + $attempts[] = ['user' => $credentials['user'], 'exit_code' => $result['exit_code']]; + if ($result['exit_code'] === 0) { + return ['success' => true, 'log_file' => $logPath, 'attempts' => $attempts]; + } + } + + $message = 'All SteamCMD login attempts failed.'; + $this->appendLog($logPath, $message); + return ['success' => false, 'error' => $message, 'log_file' => $logPath, 'attempts' => $attempts]; + } + public function getAdapterOptions(): array { $options = []; @@ -584,29 +626,96 @@ class SteamWorkshopService return $this->parseSteamAppIdFromConfig($xml); } - public function searchWorkshopItems(string $gameKey, string $query, int $limit = 12): array + /** + * Example usage: + * $service->searchWorkshopItems('dayz', 'weapon', 12, 1); + */ + public function searchWorkshopItems(string $gameKey, string $query, int $perPage = 12, int $page = 1): array { $query = trim($query); + $payload = [ + 'results' => [], + 'pagination' => [ + 'page' => max(1, $page), + 'per_page' => max(1, min(100, $perPage)), + 'total' => 0, + 'has_more' => false, + ], + 'error' => null, + ]; + if ($query === '') { - return []; + $payload['error'] = 'Enter a Workshop ID or keyword.'; + return $payload; } $appId = $this->getSteamAppIdForGameKey($gameKey); if ($appId === null) { - return []; + $payload['error'] = 'Workshop search is not configured for this game.'; + $this->logApiFailure(sprintf('Missing Steam AppID for game key %s during search.', $gameKey)); + return $payload; } if (ctype_digit($query)) { $item = $this->fetchWorkshopItemById($query); - return $item === null ? [] : [$item]; + if ($item !== null) { + $payload['results'][] = $item; + $payload['pagination']['total'] = 1; + } + return $payload; } - $html = $this->fetchWorkshopSearchHtml($appId, $query, $limit * 2); - if ($html === null) { - return []; + $postFields = [ + 'query_type' => 0, + 'page' => $payload['pagination']['page'], + 'numperpage' => $payload['pagination']['per_page'], + 'appid' => $appId, + 'search_text' => $query, + 'return_details' => true, + 'return_metadata' => false, + ]; + + $response = $this->executeSteamApiRequest('https://api.steampowered.com/IPublishedFileService/QueryFiles/v1/', $postFields); + if ($response['error'] !== null || $response['http_code'] < 200 || $response['http_code'] >= 300) { + $reason = $response['error'] ?? ('HTTP ' . $response['http_code']); + $this->logApiFailure(sprintf('Steam API search failed (app=%s query="%s" page=%d): %s', $appId, $query, $payload['pagination']['page'], $reason)); + $payload['error'] = 'Unable to contact the Steam Workshop.'; + return $payload; } - return $this->extractWorkshopItemsFromHtml($html, $limit); + $data = json_decode((string)$response['body'], true); + if (!is_array($data) || !isset($data['response'])) { + $this->logApiFailure(sprintf('Steam API search returned invalid payload (app=%s query="%s")', $appId, $query)); + $payload['error'] = 'Unable to contact the Steam Workshop.'; + return $payload; + } + $details = $data['response']['publishedfiledetails'] ?? []; + $total = (int)($data['response']['total'] ?? count($details)); + + foreach ($details as $item) { + $id = isset($item['publishedfileid']) ? preg_replace('/[^0-9]/', '', (string)$item['publishedfileid']) : ''; + if ($id === '') { + continue; + } + $title = isset($item['title']) ? trim((string)$item['title']) : ''; + if ($title === '') { + $title = '@' . $id; + } + $payload['results'][] = [ + 'id' => $id, + 'label' => $title, + 'author' => isset($item['creator']) ? (string)$item['creator'] : '', + 'preview_url' => isset($item['preview_url']) ? (string)$item['preview_url'] : '', + 'time_updated' => isset($item['time_updated']) ? (int)$item['time_updated'] : null, + 'subscriptions' => isset($item['subscriptions']) ? (int)$item['subscriptions'] : 0, + 'source' => 'search', + ]; + } + + $payload['pagination']['total'] = $total; + $payload['pagination']['has_more'] = ($payload['pagination']['page'] * $payload['pagination']['per_page']) < $total; + + return $payload; } private function sanitizeInterval(?int $minutes): int @@ -777,6 +886,10 @@ class SteamWorkshopService if (!is_file($this->adapterMapFile)) { file_put_contents($this->adapterMapFile, json_encode([], JSON_PRETTY_PRINT)); } + + if (!is_file($this->apiLogFile)) { + touch($this->apiLogFile); + } } public function gameSupportsWorkshop($serverXml): bool @@ -906,81 +1019,20 @@ class SteamWorkshopService return implode(PHP_EOL, $lines); } - private function fetchWorkshopSearchHtml(string $appId, string $query, int $pageSize): ?string - { - $params = http_build_query([ - 'appid' => $appId, - 'searchtext' => $query, - 'numperpage' => max(5, $pageSize), - 'format' => 'json', - 'browsesort' => 'textsearch', - ]); - $url = 'https://steamcommunity.com/workshop/browse/?' . $params; - $response = $this->httpRequest($url); - if ($response === null) { - return null; - } - - $data = json_decode($response, true); - if (is_array($data) && isset($data['html'])) { - return (string)$data['html']; - } - - return $response; - } - - private function extractWorkshopItemsFromHtml(string $html, int $limit): array - { - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML('' . $html); - libxml_clear_errors(); - $xpath = new DOMXPath($doc); - $nodes = $xpath->query('//*[@data-publishedfileid]'); - $results = []; - - foreach ($nodes as $node) { - if (!($node instanceof DOMElement)) { - continue; - } - $id = $node->getAttribute('data-publishedfileid'); - if ($id === '') { - continue; - } - - $titleNode = $xpath->query('.//*[contains(@class,"workshopItemTitle")]', $node)->item(0); - $authorNode = $xpath->query('.//*[contains(@class,"workshopItemAuthorName")]', $node)->item(0); - $imgNode = $xpath->query('.//img[contains(@class,"workshopItemPreviewImage") or contains(@class,"workshopItemPreviewImageMain")]', $node)->item(0); - - $results[$id] = [ - 'id' => $id, - 'label' => trim($titleNode instanceof DOMNode ? $titleNode->textContent : ('@' . $id)), - 'author' => trim($authorNode instanceof DOMNode ? $authorNode->textContent : ''), - 'preview_url' => $imgNode instanceof DOMElement ? (string)$imgNode->getAttribute('src') : '', - 'enabled' => true, - 'source' => 'search', - ]; - - if (count($results) >= $limit) { - break; - } - } - - return array_values($results); - } private function fetchWorkshopItemById(string $id): ?array { - $postData = http_build_query([ + $response = $this->executeSteamApiRequest('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', [ 'itemcount' => 1, 'publishedfileids[0]' => $id, ]); - $response = $this->httpRequest('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', $postData); - if ($response === null) { + if ($response['error'] !== null || $response['http_code'] < 200 || $response['http_code'] >= 300) { + $reason = $response['error'] ?? ('HTTP ' . $response['http_code']); + $this->logApiFailure(sprintf('Steam API detail lookup failed (id=%s): %s', $id, $reason)); return null; } - $data = json_decode($response, true); + $data = json_decode((string)$response['body'], true); $details = $data['response']['publishedfiledetails'][0] ?? null; if (!is_array($details) || (int)($details['result'] ?? 0) !== 1) { return null; @@ -1001,39 +1053,87 @@ class SteamWorkshopService ]; } - private function httpRequest(string $url, ?string $postFields = null): ?string + private function executeSteamApiRequest(string $url, array $fields): array { - if (function_exists('curl_init')) { - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)'); - if ($postFields !== null) { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); - } - $response = curl_exec($ch); - curl_close($ch); - return is_string($response) ? $response : null; + if (!function_exists('curl_init')) { + return ['body' => null, 'http_code' => 0, 'error' => 'PHP cURL extension is required']; } - $contextOptions = [ - 'http' => [ - 'method' => $postFields !== null ? 'POST' : 'GET', - 'timeout' => 10, - 'header' => "User-Agent: GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)\r\n" . ($postFields !== null ? "Content-Type: application/x-www-form-urlencoded\r\n" : ''), - ], - ]; - if ($postFields !== null) { - $contextOptions['http']['content'] = $postFields; + $ch = curl_init($url); + if ($ch === false) { + return ['body' => null, 'http_code' => 0, 'error' => 'Unable to initialize cURL']; } - $context = stream_context_create($contextOptions); - $result = @file_get_contents($url, false, $context); - return $result === false ? null : $result; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($fields, '', '&'), + CURLOPT_TIMEOUT => 15, + CURLOPT_USERAGENT => 'GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)', + CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'], + ]); + + $body = curl_exec($ch); + $error = curl_errno($ch) ? curl_error($ch) : null; + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return ['body' => $error === null ? $body : null, 'http_code' => $status, 'error' => $error]; } + private function runSteamCmdDownload(string $steamCmdPath, string $appId, string $workshopId, string $username, ?string $password): array + { + $command = [$steamCmdPath, '+login', $username]; + if ($username !== 'anonymous' && $password !== null && $password !== '') { + $command[] = $password; + } + $command = array_merge($command, ['+workshop_download_item', $appId, $workshopId, 'validate', '+quit']); + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptorSpec, $pipes); + if (!is_resource($process)) { + return ['exit_code' => 1, 'output' => ['Unable to start steamcmd process.']]; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = (int)proc_close($process); + $combined = trim($stdout . PHP_EOL . $stderr); + $lines = $combined === '' ? [] : preg_split('/\r\n|\r|\n/', $combined); + + return ['exit_code' => $exitCode, 'output' => is_array($lines) ? $lines : []]; + } + + private function appendLog(string $file, string $message): void + { + $dir = dirname($file); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($file, sprintf('[%s] %s%s', date('Y-m-d H:i:s'), $message, PHP_EOL), FILE_APPEND); + } + + private function appendSteamCmdOutput(string $logFile, array $lines): void + { + if (empty($lines)) { + return; + } + file_put_contents($logFile, implode(PHP_EOL, $lines) . PHP_EOL, FILE_APPEND); + } + + private function logApiFailure(string $message): void + { + $this->appendLog($this->apiLogFile, $message); + } + + private function buildWorkshopGroupKey(string $appId): string { return 'steamapp_' . $appId;