From 8eff063a9392cd4fae34d1aa49170eb19a01da50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:01:33 +0000 Subject: [PATCH 1/2] feat: add database-driven Steam Workshop system - Create 3 new DB tables: workshop_game_profiles, workshop_cache, server_workshop_mods - Add WorkshopRepository (DB access layer for all 3 tables) - Add WorkshopInstaller (rsync/robocopy/custom_script copy logic, SteamCMD download via agent exec) - Add WorkshopUpdater (scheduled cache update functions grouped by agent) - Add WorkshopPreStart (pre-start mod sync helper) - Add WorkshopProfileController (admin CRUD for profiles) - Add WorkshopModController (user install/remove/toggle/load_order/sync) - Add admin views: profiles list + profile_form - Add user views: user_workshop_index + user_workshop_mods - Add cron_update.php CLI entry point (--all/--agent-id/--home-id/--profile-id/--workshop-id) - Add prestart_sync.php CLI helper for XML pre_start hook - Update workshop_admin.php to route to profile management - Update main.php to route to new mod management (legacy fallback preserved) - Update module.php with DB migration SQL and version bump to 2.1 - Update lang/en_US.php with all new strings Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/dbeebd0e-e7a5-469d-8a8c-e63193d1ebb0 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .../controllers/WorkshopModController.php | 381 ++++++++++++++ .../controllers/WorkshopProfileController.php | 221 ++++++++ modules/steam_workshop/cron_update.php | 178 +++++++ modules/steam_workshop/lang/en_US.php | 90 ++++ .../steam_workshop/lib/WorkshopInstaller.php | 497 ++++++++++++++++++ .../steam_workshop/lib/WorkshopPreStart.php | 118 +++++ .../steam_workshop/lib/WorkshopRepository.php | 438 +++++++++++++++ .../steam_workshop/lib/WorkshopUpdater.php | 314 +++++++++++ modules/steam_workshop/main.php | 29 +- .../migrations/001_workshop_tables.sql | 65 +++ modules/steam_workshop/module.php | 63 ++- modules/steam_workshop/prestart_sync.php | 91 ++++ .../views/admin/profile_form.php | 152 ++++++ .../steam_workshop/views/admin/profiles.php | 73 +++ .../views/user_workshop_index.php | 65 +++ .../views/user_workshop_mods.php | 220 ++++++++ modules/steam_workshop/workshop_admin.php | 20 +- 17 files changed, 3007 insertions(+), 8 deletions(-) create mode 100644 modules/steam_workshop/controllers/WorkshopModController.php create mode 100644 modules/steam_workshop/controllers/WorkshopProfileController.php create mode 100644 modules/steam_workshop/cron_update.php create mode 100644 modules/steam_workshop/lib/WorkshopInstaller.php create mode 100644 modules/steam_workshop/lib/WorkshopPreStart.php create mode 100644 modules/steam_workshop/lib/WorkshopRepository.php create mode 100644 modules/steam_workshop/lib/WorkshopUpdater.php create mode 100644 modules/steam_workshop/migrations/001_workshop_tables.sql create mode 100644 modules/steam_workshop/prestart_sync.php create mode 100644 modules/steam_workshop/views/admin/profile_form.php create mode 100644 modules/steam_workshop/views/admin/profiles.php create mode 100644 modules/steam_workshop/views/user_workshop_index.php create mode 100644 modules/steam_workshop/views/user_workshop_mods.php diff --git a/modules/steam_workshop/controllers/WorkshopModController.php b/modules/steam_workshop/controllers/WorkshopModController.php new file mode 100644 index 00000000..8139b6fc --- /dev/null +++ b/modules/steam_workshop/controllers/WorkshopModController.php @@ -0,0 +1,381 @@ +repo = new WorkshopRepository($db); + $this->installer = new WorkshopInstaller($this->repo); + $this->searchService = new SteamWorkshopService($db); + $this->lang = $this->loadLang(); + } + + // ------------------------------------------------------------------ + // Dispatch + // ------------------------------------------------------------------ + + public function handle(): void + { + global $db; + + $userId = (int)($_SESSION['user_id'] ?? 0); + $isAdmin = $db->isAdmin($userId); + $action = $_GET['action'] ?? 'index'; + + // JSON endpoint – no HTML output + if ($action === 'search') { + $this->handleSearch($userId, $isAdmin); + return; + } + + echo ''; + echo ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $postAction = $_POST['ws_action'] ?? $action; + switch ($postAction) { + case 'install': + $this->handleInstall($userId, $isAdmin); + return; + case 'remove': + $this->handleRemove($userId, $isAdmin); + return; + case 'toggle': + $this->handleToggle($userId, $isAdmin); + return; + case 'load_order': + $this->handleLoadOrder($userId, $isAdmin); + return; + case 'sync': + $this->handleSync($userId, $isAdmin); + return; + } + } + + switch ($action) { + case 'mods': + $this->handleModsPage($userId, $isAdmin); + break; + default: + $this->handleIndex($userId, $isAdmin); + break; + } + } + + // ------------------------------------------------------------------ + // Pages + // ------------------------------------------------------------------ + + private function handleIndex(int $userId, bool $isAdmin): void + { + $homes = $this->getHomesForUser($userId, $isAdmin); + $records = []; + + foreach ((array)$homes as $home) { + $homeId = (int)($home['home_id'] ?? 0); + $appId = $this->searchService->getSteamAppIdForGameKey((string)($home['game_key'] ?? '')); + $profile = $appId !== null ? $this->repo->getProfileByAppId($appId) : null; + $mods = $profile !== null ? $this->repo->listModsForHome($homeId) : []; + + $records[] = [ + 'home' => $home, + 'profile' => $profile, + 'mods' => $mods, + ]; + } + + $this->render('user_workshop_index', [ + 'lang' => $this->lang, + 'records' => $records, + 'isAdmin' => $isAdmin, + ]); + } + + private function handleModsPage(int $userId, bool $isAdmin): void + { + $homeId = (int)($_GET['home_id'] ?? 0); + if ($homeId <= 0) { + print_failure($this->lang['error_missing_home'] ?? 'Select a server first.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + print_failure($this->lang['error_home_not_found'] ?? 'Server not found.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $agentId = (int)($home['remote_server_id'] ?? 0); + $appId = $this->searchService->getSteamAppIdForGameKey((string)($home['game_key'] ?? '')); + $profile = $appId !== null ? $this->repo->getProfileByAppId($appId) : null; + + $installedMods = $this->repo->listModsForHome($homeId); + $availableMods = ($profile !== null && $appId !== null) + ? $this->repo->listCacheForAgent($agentId, $appId) + : []; + + $this->render('user_workshop_mods', [ + 'lang' => $this->lang, + 'home' => $home, + 'homeId' => $homeId, + 'profile' => $profile, + 'appId' => $appId, + 'installedMods' => $installedMods, + 'availableMods' => $availableMods, + 'isAdmin' => $isAdmin, + ]); + } + + // ------------------------------------------------------------------ + // AJAX / POST actions + // ------------------------------------------------------------------ + + private function handleInstall(int $userId, bool $isAdmin): void + { + $homeId = (int)($_POST['home_id'] ?? 0); + $workshopId = preg_replace('/[^0-9]/', '', (string)($_POST['workshop_id'] ?? '')) ?? ''; + + if ($homeId <= 0 || $workshopId === '') { + print_failure($this->lang['error_missing_params'] ?? 'Missing home or workshop ID.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + print_failure($this->lang['error_home_not_found'] ?? 'Server not found or access denied.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $appId = $this->searchService->getSteamAppIdForGameKey((string)($home['game_key'] ?? '')); + $profile = $appId !== null ? $this->repo->getProfileByAppId($appId) : null; + + if ($profile === null) { + print_failure($this->lang['error_no_profile'] ?? 'No Workshop profile configured for this game.'); + $this->handleModsPage($userId, $isAdmin); + return; + } + + $result = $this->installer->install($home, $profile, $workshopId); + + if ($result['success']) { + $msg = $this->lang['mod_installed'] ?? 'Mod installed successfully.'; + if (!empty($result['restart_required'])) { + $msg .= ' ' . ($this->lang['restart_required'] ?? 'A server restart is required to activate this mod.'); + } + print_success($msg); + } else { + print_failure(($this->lang['mod_install_error'] ?? 'Install failed: ') . $result['message']); + } + + $_GET['home_id'] = $homeId; + $this->handleModsPage($userId, $isAdmin); + } + + private function handleRemove(int $userId, bool $isAdmin): void + { + $homeId = (int)($_POST['home_id'] ?? 0); + $workshopId = preg_replace('/[^0-9]/', '', (string)($_POST['workshop_id'] ?? '')) ?? ''; + + if ($homeId <= 0 || $workshopId === '') { + print_failure($this->lang['error_missing_params'] ?? 'Missing parameters.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + print_failure($this->lang['error_home_not_found'] ?? 'Server not found.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + if ($this->repo->removeMod($homeId, $workshopId)) { + print_success($this->lang['mod_removed'] ?? 'Mod removed.'); + } else { + print_failure($this->lang['mod_remove_error'] ?? 'Failed to remove mod.'); + } + + $_GET['home_id'] = $homeId; + $this->handleModsPage($userId, $isAdmin); + } + + private function handleToggle(int $userId, bool $isAdmin): void + { + $homeId = (int)($_POST['home_id'] ?? 0); + $workshopId = preg_replace('/[^0-9]/', '', (string)($_POST['workshop_id'] ?? '')) ?? ''; + $enabled = !empty($_POST['enabled']); + + if ($homeId <= 0 || $workshopId === '') { + $this->jsonResponse(['ok' => false, 'error' => 'Missing parameters.']); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + $this->jsonResponse(['ok' => false, 'error' => 'Access denied.']); + return; + } + + $ok = $this->repo->toggleMod($homeId, $workshopId, $enabled); + $this->jsonResponse(['ok' => $ok]); + } + + private function handleLoadOrder(int $userId, bool $isAdmin): void + { + $homeId = (int)($_POST['home_id'] ?? 0); + $workshopId = preg_replace('/[^0-9]/', '', (string)($_POST['workshop_id'] ?? '')) ?? ''; + $order = (int)($_POST['load_order'] ?? 0); + + if ($homeId <= 0 || $workshopId === '') { + $this->jsonResponse(['ok' => false, 'error' => 'Missing parameters.']); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + $this->jsonResponse(['ok' => false, 'error' => 'Access denied.']); + return; + } + + $ok = $this->repo->updateLoadOrder($homeId, $workshopId, $order); + $this->jsonResponse(['ok' => $ok]); + } + + private function handleSync(int $userId, bool $isAdmin): void + { + $homeId = (int)($_POST['home_id'] ?? 0); + $workshopId = preg_replace('/[^0-9]/', '', (string)($_POST['workshop_id'] ?? '')) ?? ''; + + if ($homeId <= 0 || $workshopId === '') { + print_failure($this->lang['error_missing_params'] ?? 'Missing parameters.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + print_failure($this->lang['error_home_not_found'] ?? 'Server not found.'); + $this->handleIndex($userId, $isAdmin); + return; + } + + $modRow = $this->repo->getServerMod($homeId, $workshopId); + $profile = $modRow !== null ? $this->repo->getProfileById((int)$modRow['profile_id']) : null; + + if ($modRow === null || $profile === null) { + print_failure($this->lang['error_mod_not_found'] ?? 'Mod or profile not found.'); + } else { + $result = $this->installer->syncMod($home, $modRow, $profile); + if ($result['success']) { + print_success($result['changed'] + ? ($this->lang['sync_success'] ?? 'Mod synced successfully.') + : ($this->lang['sync_no_change'] ?? 'Mod is already up to date.')); + } else { + print_failure(($this->lang['sync_error'] ?? 'Sync failed: ') . $result['message']); + } + } + + $_GET['home_id'] = $homeId; + $this->handleModsPage($userId, $isAdmin); + } + + private function handleSearch(int $userId, bool $isAdmin): void + { + header('Content-Type: application/json'); + $homeId = (int)($_GET['home_id'] ?? 0); + $query = trim((string)($_GET['q'] ?? '')); + + if ($homeId <= 0 || $query === '') { + echo json_encode(['ok' => false, 'error' => 'Missing parameters.']); + return; + } + + $home = $this->getHome($homeId, $userId, $isAdmin); + if ($home === null) { + echo json_encode(['ok' => false, 'error' => 'Server not found.']); + return; + } + + $gameKey = (string)($home['game_key'] ?? ''); + $payload = $this->searchService->searchWorkshopItems($gameKey, $query, 12, 1); + + if ($payload['error'] !== null) { + echo json_encode(['ok' => false, 'error' => $payload['error']]); + return; + } + + echo json_encode(['ok' => true, 'results' => $payload['results'], 'pagination' => $payload['pagination']]); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** @return array> */ + private function getHomesForUser(int $userId, bool $isAdmin): array + { + global $db; + $accessType = $isAdmin ? 'admin' : 'user_and_group'; + $homes = $db->getHomesFor($accessType, $userId); + return is_array($homes) ? array_values($homes) : []; + } + + private function getHome(int $homeId, int $userId, bool $isAdmin): ?array + { + global $db; + $row = $isAdmin ? $db->getGameHome($homeId) : $db->getUserGameHome($userId, $homeId); + return is_array($row) ? $row : null; + } + + private function render(string $view, array $data = []): void + { + extract($data); + require __DIR__ . '/../views/' . $view . '.php'; + } + + /** @param array $data */ + private function jsonResponse(array $data): void + { + header('Content-Type: application/json'); + echo json_encode($data); + } + + private function loadLang(): array + { + $file = __DIR__ . '/../lang/en_US.php'; + if (is_file($file)) { + $strings = require $file; + if (is_array($strings)) { + return $strings; + } + } + return []; + } +} diff --git a/modules/steam_workshop/controllers/WorkshopProfileController.php b/modules/steam_workshop/controllers/WorkshopProfileController.php new file mode 100644 index 00000000..01623cff --- /dev/null +++ b/modules/steam_workshop/controllers/WorkshopProfileController.php @@ -0,0 +1,221 @@ +repo = new WorkshopRepository($db); + $this->lang = $this->loadLang(); + } + + // ------------------------------------------------------------------ + // Dispatch + // ------------------------------------------------------------------ + + public function handle(): void + { + echo ''; + + $action = $_GET['sw_action'] ?? 'profiles'; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $postAction = $_POST['sw_action'] ?? ''; + switch ($postAction) { + case 'profile_save': + $this->handleSave(); + return; + case 'profile_delete': + $this->handleDelete(); + return; + } + } + + switch ($action) { + case 'profile_form': + $this->handleForm((int)($_GET['profile_id'] ?? 0)); + break; + default: + $this->handleList(); + break; + } + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + private function handleList(): void + { + $profiles = $this->repo->listProfiles(); + $this->render('admin/profiles', [ + 'lang' => $this->lang, + 'profiles' => $profiles, + ]); + } + + private function handleForm(int $profileId): void + { + $profile = $profileId > 0 ? $this->repo->getProfileById($profileId) : null; + $this->render('admin/profile_form', [ + 'lang' => $this->lang, + 'profile' => $profile, + 'profileId' => $profileId, + ]); + } + + private function handleSave(): void + { + $id = (int)($_POST['profile_id'] ?? 0); + $data = $this->extractProfileData($_POST); + + $errors = $this->validateProfileData($data); + if (!empty($errors)) { + foreach ($errors as $err) { + print_failure($err); + } + $profile = $id > 0 ? $this->repo->getProfileById($id) : null; + $this->render('admin/profile_form', [ + 'lang' => $this->lang, + 'profile' => array_merge($profile ?? [], $data, ['id' => $id]), + 'profileId' => $id, + ]); + return; + } + + $data['id'] = $id; + $savedId = $this->repo->saveProfile($data); + + if ($savedId > 0) { + print_success($this->lang['profile_saved'] ?? 'Workshop profile saved.'); + } else { + print_failure($this->lang['profile_save_error'] ?? 'Failed to save Workshop profile.'); + } + + $this->handleList(); + } + + private function handleDelete(): void + { + $id = (int)($_POST['profile_id'] ?? 0); + if ($id <= 0) { + print_failure($this->lang['profile_not_found'] ?? 'Profile not found.'); + $this->handleList(); + return; + } + + if ($this->repo->deleteProfile($id)) { + print_success($this->lang['profile_deleted'] ?? 'Workshop profile deleted.'); + } else { + print_failure($this->lang['profile_delete_error'] ?? 'Failed to delete Workshop profile.'); + } + + $this->handleList(); + } + + // ------------------------------------------------------------------ + // Input helpers + // ------------------------------------------------------------------ + + /** + * @param array $post + * @return array + */ + private function extractProfileData(array $post): array + { + // supported_os can be multiple values (SET type) + $osRaw = $post['supported_os'] ?? []; + if (!is_array($osRaw)) { + $osRaw = [$osRaw]; + } + $allowedOs = ['linux', 'windows']; + $osValues = array_values(array_intersect($osRaw, $allowedOs)); + $supportedOs = implode(',', $osValues !== [] ? $osValues : ['linux']); + + $allowedMethods = ['rsync', 'robocopy', 'custom_script']; + $copyMethod = in_array($post['copy_method'] ?? '', $allowedMethods, true) + ? (string)$post['copy_method'] + : 'rsync'; + + return [ + 'game_key' => trim((string)($post['game_key'] ?? '')), + 'game_name' => trim((string)($post['game_name'] ?? '')), + 'workshop_app_id' => preg_replace('/[^0-9]/', '', (string)($post['workshop_app_id'] ?? '')) ?? '', + 'supported_os' => $supportedOs, + 'cache_path_template' => trim((string)($post['cache_path_template'] ?? '')), + 'install_path_template' => trim((string)($post['install_path_template'] ?? '')), + 'folder_name_template' => trim((string)($post['folder_name_template'] ?? '@{mod_id}')), + 'copy_method' => $copyMethod, + 'install_script' => trim((string)($post['install_script'] ?? '')), + 'config_file_template' => trim((string)($post['config_file_template'] ?? '')), + 'launch_param_template' => trim((string)($post['launch_param_template'] ?? '')), + 'requires_restart' => !empty($post['requires_restart']) ? 1 : 0, + 'enabled' => !empty($post['enabled']) ? 1 : 0, + ]; + } + + /** + * @param array $data + * @return list + */ + private function validateProfileData(array $data): array + { + $errors = []; + if (($data['game_key'] ?? '') === '') { + $errors[] = $this->lang['error_game_key_required'] ?? 'Game key is required.'; + } elseif (!preg_match('/^[a-z0-9_\-.]+$/i', (string)$data['game_key'])) { + $errors[] = $this->lang['error_game_key_invalid'] ?? 'Game key may only contain letters, digits, underscores, dots, and hyphens.'; + } + if (($data['game_name'] ?? '') === '') { + $errors[] = $this->lang['error_game_name_required'] ?? 'Game name is required.'; + } + if (($data['workshop_app_id'] ?? '') === '') { + $errors[] = $this->lang['error_app_id_required'] ?? 'Workshop App ID is required.'; + } + if (($data['cache_path_template'] ?? '') === '') { + $errors[] = $this->lang['error_cache_path_required'] ?? 'Cache path template is required.'; + } + if (($data['install_path_template'] ?? '') === '') { + $errors[] = $this->lang['error_install_path_required'] ?? 'Install path template is required.'; + } + return $errors; + } + + // ------------------------------------------------------------------ + // Rendering + // ------------------------------------------------------------------ + + private function render(string $view, array $data = []): void + { + extract($data); + require __DIR__ . '/../views/' . $view . '.php'; + } + + private function loadLang(): array + { + $file = __DIR__ . '/../lang/en_US.php'; + if (is_file($file)) { + $strings = require $file; + if (is_array($strings)) { + return $strings; + } + } + return []; + } +} diff --git a/modules/steam_workshop/cron_update.php b/modules/steam_workshop/cron_update.php new file mode 100644 index 00000000..37a2abd6 --- /dev/null +++ b/modules/steam_workshop/cron_update.php @@ -0,0 +1,178 @@ +#!/usr/bin/env php + + * php modules/steam_workshop/cron_update.php --home-id= + * php modules/steam_workshop/cron_update.php --profile-id= + * php modules/steam_workshop/cron_update.php --workshop-id= --agent-id= --app-id= + * + * This script: + * 1. Finds enabled installed mods from gsp_server_workshop_mods. + * 2. Groups them by agent_id and workshop_app_id. + * 3. For each unique (agent, appid, workshop_id), runs SteamCMD + * workshop_download_item validate on the agent. + * 4. Updates gsp_workshop_cache (status, last_checked, last_updated, last_error). + * 5. Does NOT copy into running servers. + * 6. Does NOT restart servers. + * 7. Logs all update attempts. + * + * Run from the panel root directory: + * cd /var/www/html && php modules/steam_workshop/cron_update.php --all + */ + +// ----------------------------------------------------------------------- +// Bootstrap: load panel includes +// ----------------------------------------------------------------------- + +// Determine panel root +$panelRoot = defined('PANEL_ROOT') ? PANEL_ROOT : realpath(__DIR__ . '/../../..'); +if ($panelRoot === false) { + $panelRoot = __DIR__ . '/../../..'; +} +chdir($panelRoot); + +// Load configuration +if (!is_file('includes/config.inc.php')) { + fwrite(STDERR, "[ERROR] Cannot locate includes/config.inc.php. Run this script from the panel root.\n"); + exit(1); +} + +require_once 'includes/config.inc.php'; +require_once 'includes/database.php'; +require_once 'includes/database_mysqli.php'; +require_once 'includes/lib_remote.php'; + +// Connect to database +if (!isset($db_host, $db_user, $db_pass, $db_name)) { + fwrite(STDERR, "[ERROR] Database configuration variables not set.\n"); + exit(1); +} + +$db = new OGPDatabaseMySQL(); +/** @var int|true $connResult */ +$connResult = $db->connect( + $db_host, + $db_user, + $db_pass, + $db_name, + $table_prefix ?? 'gsp_', + $db_port ?? null +); + +if ($connResult !== true) { + fwrite(STDERR, "[ERROR] Database connection failed (code: {$connResult}).\n"); + exit(1); +} + +require_once __DIR__ . '/lib/WorkshopRepository.php'; +require_once __DIR__ . '/lib/WorkshopInstaller.php'; +require_once __DIR__ . '/lib/WorkshopUpdater.php'; + +$repo = new WorkshopRepository($db); +$installer = new WorkshopInstaller($repo); +$updater = new WorkshopUpdater($repo, $installer); + +// ----------------------------------------------------------------------- +// Parse CLI arguments +// ----------------------------------------------------------------------- + +$opts = getopt('', [ + 'all', + 'agent-id:', + 'home-id:', + 'profile-id:', + 'workshop-id:', + 'app-id:', + 'help', +]); + +if (isset($opts['help']) || $opts === false || empty($opts)) { + echo << + php cron_update.php --home-id= + php cron_update.php --profile-id= + php cron_update.php --workshop-id= --agent-id= --app-id= + +HELP; + exit(0); +} + +// ----------------------------------------------------------------------- +// Execute the requested update +// ----------------------------------------------------------------------- + +function printResults(array $results): void +{ + $ok = 0; + $fail = 0; + foreach ($results as $r) { + $status = $r['success'] ? 'OK ' : 'FAIL '; + if ($r['success']) { + $ok++; + } else { + $fail++; + } + $msg = $r['message'] ?? ''; + echo "[{$status}] agent={$r['agent_id']} app={$r['workshop_app_id']} mod={$r['workshop_id']} – {$msg}\n"; + } + echo "Done: {$ok} succeeded, {$fail} failed.\n"; +} + +if (isset($opts['all'])) { + echo "[INFO] Updating all enabled Workshop mods…\n"; + $results = $updater->updateAll(); + printResults($results); + exit(0); +} + +if (isset($opts['agent-id']) && !isset($opts['workshop-id'])) { + $agentId = (int)$opts['agent-id']; + echo "[INFO] Updating Workshop mods for agent {$agentId}…\n"; + $results = $updater->updateWorkshopCacheForAgent($agentId); + printResults($results); + exit(0); +} + +if (isset($opts['home-id'])) { + $homeId = (int)$opts['home-id']; + echo "[INFO] Updating Workshop mods for home {$homeId}…\n"; + $results = $updater->updateWorkshopCacheForHome($homeId); + printResults($results); + exit(0); +} + +if (isset($opts['profile-id'])) { + $profileId = (int)$opts['profile-id']; + echo "[INFO] Updating Workshop mods for profile {$profileId}…\n"; + $results = $updater->updateWorkshopCacheForProfile($profileId); + printResults($results); + exit(0); +} + +if (isset($opts['workshop-id'], $opts['agent-id'], $opts['app-id'])) { + $workshopId = preg_replace('/[^0-9]/', '', (string)$opts['workshop-id']) ?? ''; + $agentId = (int)$opts['agent-id']; + $appId = preg_replace('/[^0-9]/', '', (string)$opts['app-id']) ?? ''; + + if ($workshopId === '' || $appId === '') { + fwrite(STDERR, "[ERROR] --workshop-id and --app-id must be numeric.\n"); + exit(1); + } + + echo "[INFO] Updating single mod: agent={$agentId} app={$appId} mod={$workshopId}…\n"; + $result = $updater->updateSingleWorkshopMod($agentId, $appId, $workshopId); + printResults([$result]); + exit(0); +} + +fwrite(STDERR, "[ERROR] No valid option provided. Use --help for usage.\n"); +exit(1); diff --git a/modules/steam_workshop/lang/en_US.php b/modules/steam_workshop/lang/en_US.php index fe2eefae..47ea24b4 100644 --- a/modules/steam_workshop/lang/en_US.php +++ b/modules/steam_workshop/lang/en_US.php @@ -103,4 +103,94 @@ return [ 'label_adapter_activation' => 'Activation template', 'label_adapter_notes' => 'Notes', 'error_missing_query' => 'Enter a search term before querying the Workshop.', + + // ------------------------------------------------------- + // Workshop profile admin (WorkshopProfileController) + // ------------------------------------------------------- + 'nav_workshop_profiles' => 'Workshop Profiles (DB)', + 'profile_heading_list' => 'Workshop Game Profiles', + 'profile_intro' => 'One profile per supported game. Each profile drives mod install and caching behaviour.', + 'profile_btn_create' => 'Create Profile', + 'profile_list_empty' => 'No Workshop profiles defined yet.', + 'profile_col_game' => 'Game', + 'profile_col_key' => 'Game Key', + 'profile_col_method' => 'Copy Method', + 'profile_col_restart' => 'Restart?', + 'profile_col_status' => 'Status', + 'profile_confirm_delete' => 'Delete this Workshop profile? This will not affect already-installed server mods.', + 'profile_back_adapters' => 'Back to adapter management', + 'profile_back_list' => 'Back to profiles', + 'profile_heading_edit' => 'Edit Workshop Profile: %s', + 'profile_heading_create' => 'Create Workshop Profile', + 'profile_saved' => 'Workshop profile saved.', + 'profile_save_error' => 'Failed to save Workshop profile.', + 'profile_deleted' => 'Workshop profile deleted.', + 'profile_delete_error' => 'Failed to delete Workshop profile.', + 'profile_not_found' => 'Profile not found.', + 'profile_section_basic' => 'Basic info', + 'profile_section_paths' => 'Paths & templates', + 'profile_section_copy' => 'Copy / sync method', + 'profile_section_config' => 'Config & launch parameters', + 'profile_section_flags' => 'Flags', + 'profile_label_game_name' => 'Game name', + 'profile_label_os' => 'Supported OS', + 'profile_label_cache_path' => 'Cache path template', + 'profile_hint_cache_path' => 'Where SteamCMD downloads mods on the agent. E.g. {steamcmd_path}/steamapps/workshop/content/{workshop_app_id}/{mod_id}', + 'profile_label_install_path' => 'Install path template', + 'profile_hint_install_path' => 'Server-side mod directory. E.g. {server_path}/mods/{mod_folder}', + 'profile_label_folder_name' => 'Mod folder name template', + 'profile_hint_folder_name' => 'Folder name for each mod. Default: @{mod_id}', + 'profile_label_copy_method' => 'Copy method', + 'profile_label_install_script'=> 'Custom install script (admin-defined only, optional)', + 'profile_hint_install_script' => 'Only used when copy method is custom_script. Template variables are replaced before execution.', + 'profile_label_config_tpl' => 'Config file template (optional)', + 'profile_label_launch_tpl' => 'Launch parameter template (optional)', + 'profile_label_requires_restart' => 'Restart required after mod install/update', + 'profile_label_enabled' => 'Profile enabled', + 'profile_template_vars' => 'Available: {home_id} {agent_id} {workshop_app_id} {mod_id} {mod_title} {mod_folder} {steamcmd_path} {server_path} {install_path} {cache_path}', + 'error_game_key_invalid' => 'Game key may only contain letters, digits, underscores, dots, and hyphens.', + 'error_game_name_required' => 'Game name is required.', + 'error_app_id_required' => 'Workshop App ID is required (numeric).', + 'error_cache_path_required' => 'Cache path template is required.', + 'error_install_path_required' => 'Install path template is required.', + + // ------------------------------------------------------- + // User mod management (WorkshopModController) + // ------------------------------------------------------- + 'user_workshop_heading' => 'Steam Workshop', + 'user_workshop_server_heading' => 'Workshop Mods – %s', + 'col_server' => 'Server', + 'col_game' => 'Game', + 'col_mods_count' => 'Installed mods', + 'col_profile' => 'Profile', + 'col_mod_id' => 'Workshop ID', + 'col_mod_title' => 'Title', + 'col_load_order' => 'Load order', + 'col_cache_status' => 'Cache status', + 'no_profile' => 'No profile', + 'hint_no_profile' => 'Ask an admin to create a Workshop profile for this game.', + 'btn_manage_mods' => 'Manage Mods', + 'no_profile_notice' => 'No Workshop profile is configured for this game. An administrator needs to create one first.', + 'heading_installed_mods' => 'Installed Mods', + 'no_installed_mods' => 'No mods installed yet.', + 'heading_cached_mods' => 'Available Cached Mods (this agent)', + 'heading_install_mod' => 'Install Mod by Workshop ID', + 'label_workshop_id_input' => 'Workshop ID', + 'placeholder_workshop_id' => 'e.g. 1234567890', + 'btn_install_mod' => 'Install', + 'btn_remove_mod' => 'Remove', + 'btn_sync_now' => 'Sync now', + 'confirm_remove_mod' => 'Remove this mod from this server? (Files on disk are not deleted.)', + 'mod_installed' => 'Mod installed successfully.', + 'mod_install_error' => 'Install failed: ', + 'restart_required' => 'A server restart is required to activate this mod.', + 'mod_removed' => 'Mod removed from this server.', + 'mod_remove_error' => 'Failed to remove mod.', + 'sync_success' => 'Mod synced successfully.', + 'sync_no_change' => 'Mod is already up to date.', + 'sync_error' => 'Sync failed: ', + 'error_missing_params' => 'Missing required parameters.', + 'error_no_profile' => 'No Workshop profile configured for this game.', + 'error_mod_not_found' => 'Mod or profile not found.', ]; + diff --git a/modules/steam_workshop/lib/WorkshopInstaller.php b/modules/steam_workshop/lib/WorkshopInstaller.php new file mode 100644 index 00000000..c0c5784e --- /dev/null +++ b/modules/steam_workshop/lib/WorkshopInstaller.php @@ -0,0 +1,497 @@ +repo = $repo; + $this->logDir = __DIR__ . '/../logs'; + if (!is_dir($this->logDir)) { + mkdir($this->logDir, 0775, true); + } + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Install a workshop mod for a game server. + * + * @param array $home Row from getGameHome/getUserGameHome + * @param array $profile Row from gsp_workshop_game_profiles + * @param string $workshopId Numeric workshop item id + * @param string $steamCmdPath Path to steamcmd binary on the agent + * @return array{success:bool, message:string, restart_required:bool, log:list} + */ + public function install( + array $home, + array $profile, + string $workshopId, + string $steamCmdPath = '' + ): array { + $log = []; + + // Validate workshop id + $workshopId = preg_replace('/[^0-9]/', '', $workshopId) ?? ''; + if ($workshopId === '') { + return $this->fail('Workshop ID must be numeric.', $log); + } + + $homeId = (int)($home['home_id'] ?? 0); + $agentId = (int)($home['remote_server_id'] ?? 0); + $appId = (string)($profile['workshop_app_id'] ?? ''); + $osType = $this->detectOsType($home); + + if ($homeId <= 0 || $agentId <= 0 || $appId === '') { + return $this->fail('Invalid home, agent, or app ID.', $log); + } + + // Build template vars + $vars = $this->buildTemplateVars($home, $profile, $workshopId, '', $steamCmdPath); + $cachePath = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars); + $installPath = $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars); + $vars['{cache_path}'] = $cachePath; + $vars['{install_path}'] = $installPath; + + // Build remote library + $remote = $this->buildRemote($home); + if ($remote === null) { + return $this->fail('Unable to connect to agent.', $log); + } + + // Check agent connectivity + if ($remote->status_chk() !== 1) { + return $this->fail('Agent is offline.', $log); + } + + // Check cache + $cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId); + $log[] = "Cache check: agent={$agentId} app={$appId} mod={$workshopId}"; + + if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') { + $log[] = 'Cache MISS – triggering SteamCMD download on agent.'; + $downloadResult = $this->triggerSteamCmdDownload( + $remote, $appId, $workshopId, $steamCmdPath, $cachePath, $log + ); + + if (!$downloadResult) { + // Update cache status to 'missing' so the cron can retry + $this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'missing'); + return $this->fail( + 'SteamCMD download failed. The mod will be retried on the next scheduled update.', + $log + ); + } + + $log[] = 'SteamCMD download success.'; + $this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'cached'); + } else { + $log[] = 'Cache HIT – using existing cached copy.'; + } + + // Copy / sync from cache to server install path + $syncResult = $this->syncToServer($remote, $profile, $vars, $log); + if (!$syncResult) { + return $this->fail('Sync from cache to server failed. Check agent logs.', $log); + } + + // Optional install script (admin-defined only) + $installScript = trim((string)($profile['install_script'] ?? '')); + if ($installScript !== '') { + $this->runInstallScript($remote, $installScript, $vars, $log); + } + + // Record in database + $this->repo->insertOrUpdateMod( + $homeId, $agentId, (int)$profile['id'], $appId, $workshopId, + $installPath, '', 0 + ); + + $restartRequired = !empty($profile['requires_restart']); + $log[] = $restartRequired ? 'Restart required after mod install.' : 'Hot-reload capable (no restart required).'; + + return [ + 'success' => true, + 'message' => 'Mod installed successfully.', + 'restart_required' => $restartRequired, + 'log' => $log, + ]; + } + + /** + * Sync a single installed mod's cache into the server path. + * Called from pre-start and from the user "Sync now" button. + * + * @param array $home Game home row + * @param array $modRow Row from gsp_server_workshop_mods + * @param array $profile Row from gsp_workshop_game_profiles + * @return array{success:bool, changed:bool, message:string, log:list} + */ + public function syncMod(array $home, array $modRow, array $profile): array + { + $log = []; + $workshopId = (string)($modRow['workshop_id'] ?? ''); + $agentId = (int)($modRow['agent_id'] ?? 0); + $appId = (string)($modRow['workshop_app_id'] ?? ''); + + $cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId); + if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') { + $log[] = "Cache entry not available for mod {$workshopId} – skipping sync."; + return ['success' => false, 'changed' => false, 'message' => 'Mod not cached yet.', 'log' => $log]; + } + + $remote = $this->buildRemote($home); + if ($remote === null || $remote->status_chk() !== 1) { + return ['success' => false, 'changed' => false, 'message' => 'Agent offline.', 'log' => $log]; + } + + $vars = $this->buildTemplateVars($home, $profile, $workshopId, $modRow['title'] ?? ''); + $vars['{cache_path}'] = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars); + $vars['{install_path}'] = (string)($modRow['install_path'] ?? $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars)); + + $changed = $this->checkNeedsSync($remote, $vars['{cache_path}'], $vars['{install_path}'], $profile, $log); + if (!$changed) { + $log[] = 'No changes detected – skipping sync.'; + return ['success' => true, 'changed' => false, 'message' => 'Already up to date.', 'log' => $log]; + } + + $log[] = 'Changes detected – syncing.'; + $ok = $this->syncToServer($remote, $profile, $vars, $log); + + return [ + 'success' => $ok, + 'changed' => true, + 'message' => $ok ? 'Sync complete.' : 'Sync failed.', + 'log' => $log, + ]; + } + + // ------------------------------------------------------------------ + // Template resolution + // ------------------------------------------------------------------ + + /** + * Replace template placeholders in a string. + * + * @param array $vars + */ + public function resolveTemplate(string $template, array $vars): string + { + return str_replace(array_keys($vars), array_values($vars), $template); + } + + /** + * Build the standard template variable map for a home + profile + mod. + * + * @return array + */ + public function buildTemplateVars( + array $home, + array $profile, + string $workshopId, + string $modTitle = '', + string $steamCmdPath = '' + ): array { + $serverPath = rtrim((string)($home['home_path'] ?? ''), '/'); + $safeName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $modTitle) ?? ''; + + $folderNameTpl = (string)($profile['folder_name_template'] ?? '@{mod_id}'); + $folderNameVars = [ + '{mod_id}' => $workshopId, + '{mod_title}' => $safeName, + ]; + $folderName = str_replace(array_keys($folderNameVars), array_values($folderNameVars), $folderNameTpl); + + return [ + '{home_id}' => (string)($home['home_id'] ?? ''), + '{agent_id}' => (string)($home['remote_server_id'] ?? ''), + '{workshop_app_id}' => (string)($profile['workshop_app_id'] ?? ''), + '{mod_id}' => $workshopId, + '{mod_title}' => $safeName, + '{mod_folder}' => $folderName, + '{steamcmd_path}' => $steamCmdPath !== '' ? $steamCmdPath : '/home/gameserver/steamcmd', + '{server_path}' => $serverPath, + '{install_path}' => '', // filled by caller after resolution + '{cache_path}' => '', // filled by caller after resolution + ]; + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + /** Build an OGPRemoteLibrary instance from a home row. */ + private function buildRemote(array $home): ?object + { + if (!class_exists('OGPRemoteLibrary')) { + @require_once __DIR__ . '/../../../includes/lib_remote.php'; + } + if (!class_exists('OGPRemoteLibrary')) { + return null; + } + + $ip = (string)($home['agent_ip'] ?? ''); + $port = (string)($home['agent_port'] ?? ''); + $key = (string)($home['encryption_key'] ?? ''); + $timeout = isset($home['timeout']) ? (int)$home['timeout'] : 30; + + if ($ip === '' || $port === '') { + return null; + } + + return new OGPRemoteLibrary($ip, $port, $key, $timeout); + } + + /** + * Trigger a SteamCMD workshop_download_item on the agent via exec(). + * Returns true on success. + * + * @param list $log + */ + private function triggerSteamCmdDownload( + object $remote, + string $appId, + string $workshopId, + string $steamCmdPath, + string $cachePath, + array &$log + ): bool { + if ($steamCmdPath === '') { + $steamCmdPath = '/home/gameserver/steamcmd/steamcmd.sh'; + } + + $cmd = implode(' ', [ + escapeshellarg($steamCmdPath), + '+login', 'anonymous', + '+workshop_download_item', escapeshellarg($appId), escapeshellarg($workshopId), + 'validate', + '+quit', + ]); + + $log[] = "SteamCMD start: {$cmd}"; + $this->writeLog("STEAMCMD START agent={$this->agentIdFromRemote($remote)} app={$appId} mod={$workshopId}"); + + $output = $remote->exec($cmd); + + if ($output === null) { + $log[] = 'SteamCMD: no response from agent (command may still be running).'; + $this->writeLog("STEAMCMD NO_RESPONSE app={$appId} mod={$workshopId}"); + // Treat as unknown – check file existence + } else { + $log[] = 'SteamCMD output: ' . substr((string)$output, 0, 500); + } + + // Verify the download succeeded by checking for the cache path on the agent + $exists = $remote->rfile_exists($cachePath); + if ($exists === 1) { + $this->writeLog("STEAMCMD SUCCESS app={$appId} mod={$workshopId} path={$cachePath}"); + return true; + } + + $this->writeLog("STEAMCMD FAILURE app={$appId} mod={$workshopId} path={$cachePath}"); + return false; + } + + /** + * Check if cache path differs from install path using a dry-run compare. + * Returns true if sync is needed. + * + * @param list $log + */ + private function checkNeedsSync( + object $remote, + string $cachePath, + string $installPath, + array $profile, + array &$log + ): bool { + $copyMethod = (string)($profile['copy_method'] ?? 'rsync'); + $log[] = "Pre-start compare: cache={$cachePath} dest={$installPath} method={$copyMethod}"; + + if ($copyMethod === 'rsync') { + $cmd = sprintf( + 'rsync -rcn --delete %s %s 2>/dev/null; echo "EXIT:$?"', + escapeshellarg(rtrim($cachePath, '/') . '/'), + escapeshellarg(rtrim($installPath, '/') . '/') + ); + $out = (string)$remote->exec($cmd); + // If rsync dry-run produces file list output, changes exist + $hasChanges = preg_match('/\S/', preg_replace('/EXIT:\d+\s*$/', '', $out) ?? '') === 1; + return $hasChanges; + } + + if ($copyMethod === 'robocopy') { + // Robocopy /L = list only, /MIR = mirror, /NJH /NJS = no headers + $cmd = sprintf( + 'robocopy /L /MIR /NJH /NJS %s %s', + escapeshellarg($cachePath), + escapeshellarg($installPath) + ); + $out = (string)$remote->exec($cmd); + // Exit code 0 = no changes, 1+ = changes + return trim($out) !== '' && !preg_match('/\bNo new\b/i', $out); + } + + // custom_script: always sync + return true; + } + + /** + * Perform the actual copy/sync from cache to install path on the agent. + * + * @param array $vars + * @param list $log + */ + private function syncToServer( + object $remote, + array $profile, + array $vars, + array &$log + ): bool { + $copyMethod = (string)($profile['copy_method'] ?? 'rsync'); + $cachePath = $vars['{cache_path}'] ?? ''; + $installPath = $vars['{install_path}'] ?? ''; + + if ($cachePath === '' || $installPath === '') { + $log[] = 'Sync skipped: empty cache or install path.'; + return false; + } + + $log[] = "Sync start: method={$copyMethod} cache={$cachePath} dest={$installPath}"; + $this->writeLog("COPY START method={$copyMethod} cache={$cachePath} dest={$installPath}"); + + if ($copyMethod === 'rsync') { + $cmd = sprintf( + 'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"', + escapeshellarg($installPath), + escapeshellarg(rtrim($cachePath, '/') . '/'), + escapeshellarg(rtrim($installPath, '/') . '/') + ); + } elseif ($copyMethod === 'robocopy') { + $cmd = sprintf( + 'robocopy /MIR /NJH /NJS %s %s; echo "ROBOCOPY EXIT:$LASTEXITCODE"', + escapeshellarg($cachePath), + escapeshellarg($installPath) + ); + } elseif ($copyMethod === 'custom_script') { + $script = trim((string)($profile['install_script'] ?? '')); + if ($script === '') { + $log[] = 'custom_script requested but install_script is empty – falling back to rsync.'; + $cmd = sprintf( + 'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"', + escapeshellarg($installPath), + escapeshellarg(rtrim($cachePath, '/') . '/'), + escapeshellarg(rtrim($installPath, '/') . '/') + ); + } else { + // The admin-defined script is templated; execute it via the agent exec() + $resolvedScript = $this->resolveTemplate($script, $vars); + $cmd = $resolvedScript . ' 2>&1; echo "EXIT:$?"'; + } + } else { + $log[] = "Unknown copy method '{$copyMethod}'."; + return false; + } + + $out = (string)$remote->exec($cmd); + $log[] = 'Sync output: ' . substr($out, 0, 500); + + // Check exit code hint embedded in output + if (preg_match('/EXIT:(\d+)/', $out, $m)) { + $code = (int)$m[1]; + // robocopy exit codes 0..7 are success/info, 8+ are errors + if ($copyMethod === 'robocopy') { + $ok = $code < 8; + } else { + $ok = $code === 0; + } + } else { + $ok = true; // assume success if no code + } + + if ($ok) { + $log[] = 'Sync success.'; + $this->writeLog("COPY SUCCESS cache={$cachePath} dest={$installPath}"); + } else { + $log[] = 'Sync failed (non-zero exit).'; + $this->writeLog("COPY FAILURE cache={$cachePath} dest={$installPath}"); + } + + return $ok; + } + + /** + * Run the admin-defined install script on the agent. + * + * @param array $vars + * @param list $log + */ + private function runInstallScript( + object $remote, + string $script, + array $vars, + array &$log + ): void { + $resolved = $this->resolveTemplate($script, $vars); + $log[] = 'Running install script.'; + $out = (string)$remote->exec($resolved . ' 2>&1'); + $log[] = 'Script output: ' . substr($out, 0, 500); + $this->writeLog('SCRIPT OUTPUT: ' . substr($out, 0, 1000)); + } + + private function detectOsType(array $home): string + { + $gameKey = strtolower((string)($home['game_key'] ?? '')); + if (preg_match('/win/', $gameKey)) { + return 'windows'; + } + return 'linux'; + } + + private function agentIdFromRemote(object $remote): string + { + // OGPRemoteLibrary stores host/port; use reflection-free fallback + return 'unknown'; + } + + private function writeLog(string $message): void + { + $file = $this->logDir . '/workshop_install.log'; + $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n"; + @file_put_contents($file, $line, FILE_APPEND | LOCK_EX); + } + + private function fail(string $message, array $log): array + { + $this->writeLog('FAIL: ' . $message); + return [ + 'success' => false, + 'message' => $message, + 'restart_required' => false, + 'log' => $log, + ]; + } +} diff --git a/modules/steam_workshop/lib/WorkshopPreStart.php b/modules/steam_workshop/lib/WorkshopPreStart.php new file mode 100644 index 00000000..cca038ab --- /dev/null +++ b/modules/steam_workshop/lib/WorkshopPreStart.php @@ -0,0 +1,118 @@ + tag or from a + * pre-start hook in the panel. + * + * Design rules: + * - Does NOT restart running servers. + * - Only syncs if the cache differs from the installed path. + * - Logs every check and sync attempt. + */ + +require_once __DIR__ . '/WorkshopRepository.php'; +require_once __DIR__ . '/WorkshopInstaller.php'; + +class WorkshopPreStart +{ + private WorkshopRepository $repo; + private WorkshopInstaller $installer; + private string $logFile; + + public function __construct(WorkshopRepository $repo, WorkshopInstaller $installer) + { + $this->repo = $repo; + $this->installer = $installer; + $logDir = __DIR__ . '/../logs'; + if (!is_dir($logDir)) { + mkdir($logDir, 0775, true); + } + $this->logFile = $logDir . '/workshop_prestart.log'; + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Sync all enabled mods for the given home_id before server start. + * + * @param array $home Full game home row (from getGameHome / getUserGameHome) + * @return array{synced:int, skipped:int, failed:int, log:list} + */ + public function syncModsForHome(array $home): array + { + $homeId = (int)($home['home_id'] ?? 0); + $log = []; + $synced = 0; + $skipped = 0; + $failed = 0; + + $this->log("PRE-START home={$homeId}"); + + $mods = $this->repo->listEnabledModsForHome($homeId); + + if (empty($mods)) { + $log[] = 'No enabled Workshop mods for this server.'; + $this->log("PRE-START home={$homeId}: no mods"); + return ['synced' => 0, 'skipped' => 0, 'failed' => 0, 'log' => $log]; + } + + foreach ((array)$mods as $modRow) { + $workshopId = (string)($modRow['workshop_id'] ?? ''); + $profileId = (int)($modRow['profile_id'] ?? 0); + $log[] = "Checking mod {$workshopId} …"; + + $profile = $profileId > 0 ? $this->repo->getProfileById($profileId) : null; + if ($profile === null) { + $log[] = " Profile not found (profile_id={$profileId}) – skipped."; + $this->log("PRE-START home={$homeId} mod={$workshopId}: profile missing"); + $skipped++; + continue; + } + + $result = $this->installer->syncMod($home, $modRow, $profile); + + if ($result['success'] && $result['changed']) { + $log[] = " Synced: " . ($result['message'] ?? ''); + $this->log("PRE-START home={$homeId} mod={$workshopId}: synced"); + $synced++; + } elseif ($result['success'] && !$result['changed']) { + $log[] = ' Already up to date – no sync needed.'; + $skipped++; + } else { + $log[] = " Sync failed: " . ($result['message'] ?? 'unknown error'); + $this->log("PRE-START home={$homeId} mod={$workshopId}: FAILED"); + $failed++; + } + + // Append sub-log + foreach ((array)($result['log'] ?? []) as $line) { + $log[] = ' ' . $line; + } + } + + $this->log("PRE-START home={$homeId} done: synced={$synced} skipped={$skipped} failed={$failed}"); + + return [ + 'synced' => $synced, + 'skipped' => $skipped, + 'failed' => $failed, + 'log' => $log, + ]; + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private function log(string $message): void + { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n"; + @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); + } +} diff --git a/modules/steam_workshop/lib/WorkshopRepository.php b/modules/steam_workshop/lib/WorkshopRepository.php new file mode 100644 index 00000000..149bb4cb --- /dev/null +++ b/modules/steam_workshop/lib/WorkshopRepository.php @@ -0,0 +1,438 @@ +db = $db; + $this->prefix = $db->getTablePrefix(); + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + private function esc(mixed $val): string + { + return $this->db->realEscapeSingle((string)$val); + } + + /** Execute a query that returns no result set (INSERT / UPDATE / DELETE). */ + private function exec(string $sql): bool + { + return $this->db->query($sql) !== false; + } + + /** Execute a SELECT query; returns array of rows or empty array. */ + private function select(string $sql): array + { + $result = $this->db->resultQuery($sql); + return is_array($result) ? $result : []; + } + + /** Return the first row or null. */ + private function selectOne(string $sql): ?array + { + $rows = $this->select($sql); + return $rows[0] ?? null; + } + + private function lastInsertId(): int + { + $row = $this->selectOne('SELECT LAST_INSERT_ID() AS id'); + return isset($row['id']) ? (int)$row['id'] : 0; + } + + // ------------------------------------------------------------------ + // WORKSHOP GAME PROFILES + // ------------------------------------------------------------------ + + /** @return array> */ + public function listProfiles(bool $enabledOnly = false): array + { + $where = $enabledOnly ? ' WHERE enabled = 1' : ''; + return $this->select( + "SELECT * FROM `{$this->prefix}workshop_game_profiles`{$where} ORDER BY game_name ASC" + ); + } + + public function getProfileById(int $id): ?array + { + return $this->selectOne( + "SELECT * FROM `{$this->prefix}workshop_game_profiles` WHERE id = {$id} LIMIT 1" + ); + } + + public function getProfileByGameKey(string $gameKey): ?array + { + return $this->selectOne( + "SELECT * FROM `{$this->prefix}workshop_game_profiles` + WHERE game_key = '" . $this->esc($gameKey) . "' AND enabled = 1 LIMIT 1" + ); + } + + public function getProfileByAppId(string $appId): ?array + { + return $this->selectOne( + "SELECT * FROM `{$this->prefix}workshop_game_profiles` + WHERE workshop_app_id = '" . $this->esc($appId) . "' AND enabled = 1 LIMIT 1" + ); + } + + /** + * Insert (id = 0) or update (id > 0) a Workshop game profile. + * Returns the row id. + */ + public function saveProfile(array $data): int + { + $id = isset($data['id']) ? (int)$data['id'] : 0; + + $gameKey = $this->esc($data['game_key'] ?? ''); + $gameName = $this->esc($data['game_name'] ?? ''); + $workshopAppId = $this->esc($data['workshop_app_id'] ?? ''); + $supportedOs = $this->esc($data['supported_os'] ?? 'linux'); + $cachePathTpl = $this->esc($data['cache_path_template'] ?? ''); + $installPathTpl = $this->esc($data['install_path_template'] ?? ''); + $folderNameTpl = $this->esc($data['folder_name_template'] ?? '@{mod_id}'); + $copyMethod = $this->esc($data['copy_method'] ?? 'rsync'); + $installScript = isset($data['install_script']) && $data['install_script'] !== '' ? "'" . $this->esc($data['install_script']) . "'" : 'NULL'; + $configFileTpl = isset($data['config_file_template']) && $data['config_file_template'] !== '' ? "'" . $this->esc($data['config_file_template']) . "'" : 'NULL'; + $launchParamTpl = isset($data['launch_param_template']) && $data['launch_param_template'] !== '' ? "'" . $this->esc($data['launch_param_template']) . "'" : 'NULL'; + $requiresRestart = empty($data['requires_restart']) ? 0 : 1; + $enabled = isset($data['enabled']) && !$data['enabled'] ? 0 : 1; + + if ($id > 0) { + $this->exec( + "UPDATE `{$this->prefix}workshop_game_profiles` SET + game_key = '{$gameKey}', + game_name = '{$gameName}', + workshop_app_id = '{$workshopAppId}', + supported_os = '{$supportedOs}', + cache_path_template = '{$cachePathTpl}', + install_path_template = '{$installPathTpl}', + folder_name_template = '{$folderNameTpl}', + copy_method = '{$copyMethod}', + install_script = {$installScript}, + config_file_template = {$configFileTpl}, + launch_param_template = {$launchParamTpl}, + requires_restart = {$requiresRestart}, + enabled = {$enabled}, + updated_at = NOW() + WHERE id = {$id}" + ); + return $id; + } + + $this->exec( + "INSERT INTO `{$this->prefix}workshop_game_profiles` + (game_key, game_name, workshop_app_id, supported_os, cache_path_template, + install_path_template, folder_name_template, copy_method, install_script, + config_file_template, launch_param_template, requires_restart, enabled, created_at) + VALUES + ('{$gameKey}', '{$gameName}', '{$workshopAppId}', '{$supportedOs}', '{$cachePathTpl}', + '{$installPathTpl}', '{$folderNameTpl}', '{$copyMethod}', {$installScript}, + {$configFileTpl}, {$launchParamTpl}, {$requiresRestart}, {$enabled}, NOW())" + ); + return $this->lastInsertId(); + } + + public function deleteProfile(int $id): bool + { + return $this->exec( + "DELETE FROM `{$this->prefix}workshop_game_profiles` WHERE id = {$id}" + ); + } + + // ------------------------------------------------------------------ + // WORKSHOP CACHE + // ------------------------------------------------------------------ + + public function getCacheEntry(int $agentId, string $appId, string $workshopId): ?array + { + return $this->selectOne( + "SELECT * FROM `{$this->prefix}workshop_cache` + WHERE agent_id = {$agentId} + AND workshop_app_id = '" . $this->esc($appId) . "' + AND workshop_id = '" . $this->esc($workshopId) . "' + LIMIT 1" + ); + } + + /** + * Insert or update a cache row. + * $status: 'missing' | 'cached' | 'failed' + */ + public function upsertCacheEntry( + int $agentId, + string $osType, + string $appId, + string $workshopId, + string $cachePath, + string $status, + ?string $title = null, + ?string $error = null + ): void { + $osType = $this->esc($osType); + $appId = $this->esc($appId); + $workshopId = $this->esc($workshopId); + $cachePath = $this->esc($cachePath); + $status = $this->esc($status); + $titleSql = $title !== null ? "'" . $this->esc($title) . "'" : 'NULL'; + $errorSql = $error !== null ? "'" . $this->esc($error) . "'" : 'NULL'; + $updatedSql = ($status === 'cached') ? 'NOW()' : 'NULL'; + + $this->exec( + "INSERT INTO `{$this->prefix}workshop_cache` + (agent_id, os_type, workshop_app_id, workshop_id, title, cache_path, status, last_checked, last_updated, last_error) + VALUES + ({$agentId}, '{$osType}', '{$appId}', '{$workshopId}', {$titleSql}, '{$cachePath}', '{$status}', NOW(), {$updatedSql}, {$errorSql}) + ON DUPLICATE KEY UPDATE + os_type = '{$osType}', + cache_path = '{$cachePath}', + status = '{$status}', + title = {$titleSql}, + last_checked = NOW(), + last_updated = {$updatedSql}, + last_error = {$errorSql}" + ); + } + + /** Return all cached entries for a specific agent+appId (for the "available mods" picker). */ + public function listCacheForAgent(int $agentId, string $appId): array + { + return $this->select( + "SELECT * FROM `{$this->prefix}workshop_cache` + WHERE agent_id = {$agentId} + AND workshop_app_id = '" . $this->esc($appId) . "' + ORDER BY COALESCE(title, workshop_id) ASC" + ); + } + + /** Return all cache rows that should be refreshed (enabled mods installed somewhere). */ + public function listCacheEntriesForAgent(int $agentId): array + { + return $this->select( + "SELECT DISTINCT c.* + FROM `{$this->prefix}workshop_cache` c + JOIN `{$this->prefix}server_workshop_mods` m + ON m.agent_id = c.agent_id + AND m.workshop_app_id = c.workshop_app_id + AND m.workshop_id = c.workshop_id + WHERE c.agent_id = {$agentId} AND m.enabled = 1" + ); + } + + // ------------------------------------------------------------------ + // SERVER WORKSHOP MODS + // ------------------------------------------------------------------ + + public function getServerMod(int $homeId, string $workshopId): ?array + { + return $this->selectOne( + "SELECT * FROM `{$this->prefix}server_workshop_mods` + WHERE home_id = {$homeId} + AND workshop_id = '" . $this->esc($workshopId) . "' + LIMIT 1" + ); + } + + /** @return array> */ + public function listModsForHome(int $homeId): array + { + return $this->select( + "SELECT m.*, p.game_name, p.game_key, p.requires_restart, p.copy_method + FROM `{$this->prefix}server_workshop_mods` m + LEFT JOIN `{$this->prefix}workshop_game_profiles` p ON m.profile_id = p.id + WHERE m.home_id = {$homeId} + ORDER BY m.load_order ASC, m.installed_at ASC" + ); + } + + /** @return array> */ + public function listEnabledModsForHome(int $homeId): array + { + return $this->select( + "SELECT * FROM `{$this->prefix}server_workshop_mods` + WHERE home_id = {$homeId} AND enabled = 1 + ORDER BY load_order ASC" + ); + } + + /** + * Insert a new mod row or update the existing one (upsert by home_id + workshop_id). + * Returns the row id. + */ + public function insertOrUpdateMod( + int $homeId, + int $agentId, + int $profileId, + string $appId, + string $workshopId, + string $installPath, + string $title = '', + int $loadOrder = 0 + ): int { + $appId = $this->esc($appId); + $workshopId = $this->esc($workshopId); + $installPath = $this->esc($installPath); + $title = $this->esc($title); + + $existing = $this->getServerMod($homeId, $workshopId); + + if ($existing !== null) { + $this->exec( + "UPDATE `{$this->prefix}server_workshop_mods` SET + agent_id = {$agentId}, + profile_id = {$profileId}, + workshop_app_id = '{$appId}', + title = '{$title}', + install_path = '{$installPath}', + load_order = {$loadOrder}, + enabled = 1, + updated_at = NOW() + WHERE home_id = {$homeId} AND workshop_id = '{$workshopId}'" + ); + return (int)$existing['id']; + } + + $this->exec( + "INSERT INTO `{$this->prefix}server_workshop_mods` + (home_id, agent_id, profile_id, workshop_app_id, workshop_id, title, enabled, install_path, load_order, installed_at) + VALUES + ({$homeId}, {$agentId}, {$profileId}, '{$appId}', '{$workshopId}', '{$title}', 1, '{$installPath}', {$loadOrder}, NOW())" + ); + return $this->lastInsertId(); + } + + public function removeMod(int $homeId, string $workshopId): bool + { + return $this->exec( + "DELETE FROM `{$this->prefix}server_workshop_mods` + WHERE home_id = {$homeId} AND workshop_id = '" . $this->esc($workshopId) . "'" + ); + } + + public function toggleMod(int $homeId, string $workshopId, bool $enabled): bool + { + $val = $enabled ? 1 : 0; + return $this->exec( + "UPDATE `{$this->prefix}server_workshop_mods` + SET enabled = {$val}, updated_at = NOW() + WHERE home_id = {$homeId} AND workshop_id = '" . $this->esc($workshopId) . "'" + ); + } + + public function updateLoadOrder(int $homeId, string $workshopId, int $order): bool + { + return $this->exec( + "UPDATE `{$this->prefix}server_workshop_mods` + SET load_order = {$order}, updated_at = NOW() + WHERE home_id = {$homeId} AND workshop_id = '" . $this->esc($workshopId) . "'" + ); + } + + /** + * Return all enabled installed mods joined with their profile data. + * Used by the scheduled updater to know what needs refreshing. + * + * @return array> + */ + public function listAllEnabledMods(): array + { + return $this->select( + "SELECT m.*, + p.cache_path_template, p.install_path_template, p.folder_name_template, + p.copy_method, p.install_script, p.config_file_template, p.launch_param_template, + p.requires_restart + FROM `{$this->prefix}server_workshop_mods` m + JOIN `{$this->prefix}workshop_game_profiles` p ON m.profile_id = p.id + WHERE m.enabled = 1 AND p.enabled = 1 + ORDER BY m.agent_id ASC, m.workshop_app_id ASC, m.workshop_id ASC" + ); + } + + // ------------------------------------------------------------------ + // Agent / remote server helpers (for WorkshopUpdater) + // ------------------------------------------------------------------ + + public function getPrefix(): string + { + return $this->prefix; + } + + /** + * Return the agent connection row for a remote_server_id. + * Returns null if not found. + */ + public function getAgentRow(int $agentId): ?array + { + return $this->selectOne( + "SELECT remote_server_id AS agent_id, agent_ip, agent_port, encryption_key, timeout + FROM `{$this->prefix}remote_servers` + WHERE remote_server_id = {$agentId} + LIMIT 1" + ); + } + + // ------------------------------------------------------------------ + // Distinct Workshop ID queries (for WorkshopUpdater) + // ------------------------------------------------------------------ + + /** + * Return distinct (agent_id, workshop_app_id, workshop_id) triplets for enabled mods. + * Used by the updater to avoid duplicate SteamCMD calls. + * + * @return array> + */ + public function listDistinctEnabledWorkshopIds(): array + { + return $this->select( + "SELECT DISTINCT m.agent_id, m.workshop_app_id, m.workshop_id, m.title + FROM `{$this->prefix}server_workshop_mods` m + JOIN `{$this->prefix}workshop_game_profiles` p ON m.profile_id = p.id + WHERE m.enabled = 1 AND p.enabled = 1 + ORDER BY m.agent_id ASC, m.workshop_app_id ASC" + ); + } + + /** Distinct (agent_id, workshop_app_id, workshop_id) for a single agent. */ + public function listDistinctEnabledWorkshopIdsForAgent(int $agentId): array + { + return $this->select( + "SELECT DISTINCT m.agent_id, m.workshop_app_id, m.workshop_id, m.title + FROM `{$this->prefix}server_workshop_mods` m + JOIN `{$this->prefix}workshop_game_profiles` p ON m.profile_id = p.id + WHERE m.enabled = 1 AND p.enabled = 1 AND m.agent_id = {$agentId} + ORDER BY m.workshop_app_id ASC" + ); + } + + /** Distinct Workshop IDs for a specific home. */ + public function listDistinctEnabledWorkshopIdsForHome(int $homeId): array + { + return $this->select( + "SELECT DISTINCT m.agent_id, m.workshop_app_id, m.workshop_id, m.title + FROM `{$this->prefix}server_workshop_mods` m + JOIN `{$this->prefix}workshop_game_profiles` p ON m.profile_id = p.id + WHERE m.enabled = 1 AND p.enabled = 1 AND m.home_id = {$homeId}" + ); + } + + /** Distinct Workshop IDs for a specific profile. */ + public function listDistinctEnabledWorkshopIdsForProfile(int $profileId): array + { + return $this->select( + "SELECT DISTINCT m.agent_id, m.workshop_app_id, m.workshop_id, m.title + FROM `{$this->prefix}server_workshop_mods` m + WHERE m.enabled = 1 AND m.profile_id = {$profileId}" + ); + } +} diff --git a/modules/steam_workshop/lib/WorkshopUpdater.php b/modules/steam_workshop/lib/WorkshopUpdater.php new file mode 100644 index 00000000..fca87a06 --- /dev/null +++ b/modules/steam_workshop/lib/WorkshopUpdater.php @@ -0,0 +1,314 @@ +repo = $repo; + $this->installer = $installer; + $this->logDir = __DIR__ . '/../logs'; + $this->logFile = $this->logDir . '/workshop_update.log'; + + if (!is_dir($this->logDir)) { + mkdir($this->logDir, 0775, true); + } + } + + // ------------------------------------------------------------------ + // Public API – entry points called by cron_update.php + // ------------------------------------------------------------------ + + /** + * Update Workshop cache for all enabled installed mods across all agents. + * + * @return array + */ + public function updateAll(): array + { + $this->log('=== updateAll start ==='); + $rows = $this->repo->listDistinctEnabledWorkshopIds(); + $results = $this->processBatch($rows); + $this->log('=== updateAll end: ' . count($results) . ' items processed ==='); + return $results; + } + + /** + * Update Workshop cache for all mods installed on a specific agent. + * + * @return array + */ + public function updateWorkshopCacheForAgent(int $agentId): array + { + $this->log("=== updateWorkshopCacheForAgent agent={$agentId} start ==="); + $rows = $this->repo->listDistinctEnabledWorkshopIdsForAgent($agentId); + $results = $this->processBatch($rows); + $this->log("=== updateWorkshopCacheForAgent agent={$agentId} end ==="); + return $results; + } + + /** + * Update Workshop cache for all mods installed on a specific home. + * + * @return array + */ + public function updateWorkshopCacheForHome(int $homeId): array + { + $this->log("=== updateWorkshopCacheForHome home={$homeId} start ==="); + $rows = $this->repo->listDistinctEnabledWorkshopIdsForHome($homeId); + $results = $this->processBatch($rows); + $this->log("=== updateWorkshopCacheForHome home={$homeId} end ==="); + return $results; + } + + /** + * Update Workshop cache for all mods associated with a specific profile. + * + * @return array + */ + public function updateWorkshopCacheForProfile(int $profileId): array + { + $this->log("=== updateWorkshopCacheForProfile profile={$profileId} start ==="); + $rows = $this->repo->listDistinctEnabledWorkshopIdsForProfile($profileId); + $results = $this->processBatch($rows); + $this->log("=== updateWorkshopCacheForProfile profile={$profileId} end ==="); + return $results; + } + + /** + * Update a single Workshop mod on a specific agent. + * + * @return array + */ + public function updateSingleWorkshopMod(int $agentId, string $appId, string $workshopId): array + { + $workshopId = preg_replace('/[^0-9]/', '', $workshopId) ?? ''; + if ($workshopId === '') { + return ['success' => false, 'error' => 'Workshop ID must be numeric.']; + } + + $this->log("=== updateSingleWorkshopMod agent={$agentId} app={$appId} mod={$workshopId} ==="); + + $row = [ + 'agent_id' => $agentId, + 'workshop_app_id' => $appId, + 'workshop_id' => $workshopId, + 'title' => '', + ]; + $results = $this->processBatch([$row]); + return $results[0] ?? ['success' => false, 'error' => 'No result.']; + } + + // ------------------------------------------------------------------ + // Internal – batch processor + // ------------------------------------------------------------------ + + /** + * For each (agent_id, workshop_app_id, workshop_id) triplet, run a + * SteamCMD validate download and update the cache table. + * + * @param array> $rows + * @return array> + */ + private function processBatch(array $rows): array + { + $results = []; + + // Group by agent_id so we can build one connection per agent + $grouped = []; + foreach ($rows as $row) { + $aid = (int)($row['agent_id'] ?? 0); + if ($aid <= 0) { + continue; + } + $grouped[$aid][] = $row; + } + + foreach ((array)$grouped as $agentId => $agentRows) { + $home = $this->getAgentHome((int)$agentId); + if ($home === null) { + $this->log("Agent {$agentId}: cannot build remote – skipping."); + foreach ((array)$agentRows as $row) { + $results[] = $this->buildResult($row, false, 'Agent home not found.'); + } + continue; + } + + $remote = $this->buildRemote($home); + if ($remote === null || $remote->status_chk() !== 1) { + $this->log("Agent {$agentId}: offline or unreachable – skipping."); + foreach ((array)$agentRows as $row) { + $this->repo->upsertCacheEntry( + (int)$agentId, + $this->detectOsType($home), + (string)($row['workshop_app_id'] ?? ''), + (string)($row['workshop_id'] ?? ''), + '', + 'failed', + null, + 'Agent offline during scheduled update.' + ); + $results[] = $this->buildResult($row, false, 'Agent offline.'); + } + continue; + } + + $osType = $this->detectOsType($home); + + foreach ((array)$agentRows as $row) { + $appId = (string)($row['workshop_app_id'] ?? ''); + $workshopId = (string)($row['workshop_id'] ?? ''); + $result = $this->runSingleUpdate($remote, (int)$agentId, $osType, $appId, $workshopId, $home); + $results[] = $result; + } + } + + return $results; + } + + /** + * Run SteamCMD workshop_download_item validate for a single mod and + * update the cache table accordingly. + * + * @return array + */ + private function runSingleUpdate( + object $remote, + int $agentId, + string $osType, + string $appId, + string $workshopId, + array $home + ): array { + $this->log("Update: agent={$agentId} app={$appId} mod={$workshopId}"); + + // Build cache path from the profile (if available) or a sensible default + $profile = $this->repo->getProfileByAppId($appId); + $steamCmdPath = '/home/gameserver/steamcmd/steamcmd.sh'; + $cachePath = ''; + + if ($profile !== null) { + $vars = $this->installer->buildTemplateVars($home, $profile, $workshopId, '', $steamCmdPath); + $cachePath = $this->installer->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars); + $steamCmdPath = $vars['{steamcmd_path}']; + } + + if ($cachePath === '') { + $cachePath = "/home/gameserver/steamcmd/steamapps/workshop/content/{$appId}/{$workshopId}"; + } + + // Run SteamCMD with validate flag + $cmd = implode(' ', [ + escapeshellarg($steamCmdPath), + '+login', 'anonymous', + '+workshop_download_item', escapeshellarg($appId), escapeshellarg($workshopId), + 'validate', + '+quit', + ]); + + $this->log("STEAMCMD CMD: {$cmd}"); + $output = (string)$remote->exec($cmd); + $this->log('STEAMCMD OUTPUT: ' . substr($output, 0, 300)); + + // Verify by checking path existence + $exists = $remote->rfile_exists($cachePath); + $success = ($exists === 1); + + if ($success) { + $this->log("STEAMCMD SUCCESS app={$appId} mod={$workshopId}"); + $this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'cached'); + } else { + $errorMsg = 'SteamCMD validate completed but cache path not found: ' . $cachePath; + $this->log("STEAMCMD FAILURE app={$appId} mod={$workshopId}: {$errorMsg}"); + $this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'failed', null, $errorMsg); + } + + return $this->buildResult( + ['agent_id' => $agentId, 'workshop_app_id' => $appId, 'workshop_id' => $workshopId], + $success, + $success ? 'OK' : 'SteamCMD failed or cache path missing.' + ); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** Return a minimal home-like array for a given agent so we can build a remote. */ + private function getAgentHome(int $agentId): ?array + { + // We just need ip/port/key/timeout for the remote library connection. + // Query the remote_servers table directly via the repository's db. + // Use the OGPDatabase instance stored inside WorkshopRepository. + $prefix = $this->repo->getPrefix(); + $row = $this->repo->getAgentRow($agentId); + return $row; + } + + private function buildRemote(array $home): ?object + { + if (!class_exists('OGPRemoteLibrary')) { + @require_once __DIR__ . '/../../../includes/lib_remote.php'; + } + if (!class_exists('OGPRemoteLibrary')) { + return null; + } + + $ip = (string)($home['agent_ip'] ?? ''); + $port = (string)($home['agent_port'] ?? ''); + $key = (string)($home['encryption_key'] ?? ''); + $timeout = isset($home['timeout']) ? (int)$home['timeout'] : 30; + + if ($ip === '' || $port === '') { + return null; + } + + return new OGPRemoteLibrary($ip, $port, $key, $timeout); + } + + private function detectOsType(array $home): string + { + $gameKey = strtolower((string)($home['game_key'] ?? '')); + if (preg_match('/win/', $gameKey)) { + return 'windows'; + } + return 'linux'; + } + + /** @return array */ + private function buildResult(array $row, bool $success, string $message): array + { + return [ + 'agent_id' => $row['agent_id'] ?? 0, + 'workshop_app_id' => $row['workshop_app_id'] ?? '', + 'workshop_id' => $row['workshop_id'] ?? '', + 'success' => $success, + 'message' => $message, + ]; + } + + private function log(string $message): void + { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n"; + @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); + } +} diff --git a/modules/steam_workshop/main.php b/modules/steam_workshop/main.php index 3a09b471..7d4f15f2 100644 --- a/modules/steam_workshop/main.php +++ b/modules/steam_workshop/main.php @@ -1,20 +1,41 @@ ' . get_lang('steam_workshop') . ''; + $postAction = $_POST['ws_action'] ?? ''; + + // JSON search endpoint – no heading + if ($action === 'search') { + $controller = new SteamWorkshopController($db); + $controller->handle(); + return; } + echo '

' . get_lang('steam_workshop') . '

'; + + // New DB-driven actions + $newActions = ['index', 'mods']; + $newPostActions = ['install', 'remove', 'toggle', 'load_order', 'sync']; + + if (in_array($action, $newActions, true) || in_array($postAction, $newPostActions, true)) { + $controller = new WorkshopModController($db); + $controller->handle(); + return; + } + + // Legacy controller for old Workshop page actions $controller = new SteamWorkshopController($db); $controller->handle(); } + diff --git a/modules/steam_workshop/migrations/001_workshop_tables.sql b/modules/steam_workshop/migrations/001_workshop_tables.sql new file mode 100644 index 00000000..b9a4e53c --- /dev/null +++ b/modules/steam_workshop/migrations/001_workshop_tables.sql @@ -0,0 +1,65 @@ +-- GSP Steam Workshop – database-driven tables +-- Run once against your panel database (replace `gsp_` with your table_prefix if different). + +-- ------------------------------------------------------- +-- Workshop game profiles (one row per supported game) +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gsp_workshop_game_profiles` ( + `id` INT NOT NULL AUTO_INCREMENT, + `game_key` VARCHAR(100) NOT NULL, + `game_name` VARCHAR(255) NOT NULL, + `workshop_app_id` VARCHAR(32) NOT NULL, + `supported_os` SET('linux','windows') NOT NULL DEFAULT 'linux', + `cache_path_template` TEXT NOT NULL, + `install_path_template` TEXT NOT NULL, + `folder_name_template` VARCHAR(255) NOT NULL DEFAULT '@{mod_id}', + `copy_method` ENUM('rsync','robocopy','custom_script') NOT NULL DEFAULT 'rsync', + `install_script` TEXT NULL, + `config_file_template` TEXT NULL, + `launch_param_template` TEXT NULL, + `requires_restart` TINYINT(1) NOT NULL DEFAULT 1, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_game_key` (`game_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------- +-- Per-agent workshop download cache +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gsp_workshop_cache` ( + `id` INT NOT NULL AUTO_INCREMENT, + `agent_id` INT NOT NULL, + `os_type` ENUM('linux','windows') NOT NULL DEFAULT 'linux', + `workshop_app_id` VARCHAR(32) NOT NULL, + `workshop_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `cache_path` TEXT NOT NULL, + `status` ENUM('missing','cached','failed') NOT NULL DEFAULT 'missing', + `last_checked` DATETIME NULL, + `last_updated` DATETIME NULL, + `last_error` TEXT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_agent_workshop` (`agent_id`, `workshop_app_id`, `workshop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------- +-- Per-server installed Workshop mods +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gsp_server_workshop_mods` ( + `id` INT NOT NULL AUTO_INCREMENT, + `home_id` INT NOT NULL, + `agent_id` INT NOT NULL, + `profile_id` INT NOT NULL, + `workshop_app_id` VARCHAR(32) NOT NULL, + `workshop_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `install_path` TEXT NOT NULL, + `load_order` INT NOT NULL DEFAULT 0, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/steam_workshop/module.php b/modules/steam_workshop/module.php index 96597ba7..f60cab13 100644 --- a/modules/steam_workshop/module.php +++ b/modules/steam_workshop/module.php @@ -23,8 +23,8 @@ */ // Module general information $module_title = "Steam Workshop"; -$module_version = "2.0"; -$db_version = 0; +$module_version = "2.1"; +$db_version = 1; $module_required = TRUE; $module_menus = array( array( @@ -38,4 +38,61 @@ $module_menus = array( 'group' => 'admin' ) ); -?> \ No newline at end of file + +// Database schema migration: create the three Workshop tables when not present. +// Called by the panel module installer when db_version increments. +$module_db_create = <<<'SQL' +CREATE TABLE IF NOT EXISTS `OGP_DB_PREFIXworkshop_game_profiles` ( + `id` INT NOT NULL AUTO_INCREMENT, + `game_key` VARCHAR(100) NOT NULL, + `game_name` VARCHAR(255) NOT NULL, + `workshop_app_id` VARCHAR(32) NOT NULL, + `supported_os` SET('linux','windows') NOT NULL DEFAULT 'linux', + `cache_path_template` TEXT NOT NULL, + `install_path_template` TEXT NOT NULL, + `folder_name_template` VARCHAR(255) NOT NULL DEFAULT '@{mod_id}', + `copy_method` ENUM('rsync','robocopy','custom_script') NOT NULL DEFAULT 'rsync', + `install_script` TEXT NULL, + `config_file_template` TEXT NULL, + `launch_param_template` TEXT NULL, + `requires_restart` TINYINT(1) NOT NULL DEFAULT 1, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_game_key` (`game_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `OGP_DB_PREFIXworkshop_cache` ( + `id` INT NOT NULL AUTO_INCREMENT, + `agent_id` INT NOT NULL, + `os_type` ENUM('linux','windows') NOT NULL DEFAULT 'linux', + `workshop_app_id` VARCHAR(32) NOT NULL, + `workshop_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `cache_path` TEXT NOT NULL, + `status` ENUM('missing','cached','failed') NOT NULL DEFAULT 'missing', + `last_checked` DATETIME NULL, + `last_updated` DATETIME NULL, + `last_error` TEXT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_agent_workshop` (`agent_id`, `workshop_app_id`, `workshop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `OGP_DB_PREFIXserver_workshop_mods` ( + `id` INT NOT NULL AUTO_INCREMENT, + `home_id` INT NOT NULL, + `agent_id` INT NOT NULL, + `profile_id` INT NOT NULL, + `workshop_app_id` VARCHAR(32) NOT NULL, + `workshop_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `install_path` TEXT NOT NULL, + `load_order` INT NOT NULL DEFAULT 0, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SQL; diff --git a/modules/steam_workshop/prestart_sync.php b/modules/steam_workshop/prestart_sync.php new file mode 100644 index 00000000..eefc6a0e --- /dev/null +++ b/modules/steam_workshop/prestart_sync.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + tag or a server pre-start hook: + * php modules/steam_workshop/prestart_sync.php --home-id= + * + * This script: + * 1. Finds all enabled Workshop mods for the given home. + * 2. Checks each mod's local cache on the agent. + * 3. If the cache differs from the server install path, syncs it. + * 4. Continues normal server start (exits 0 on success). + * 5. Exits non-zero ONLY if a critical error prevents completion. + * + * Design note: sync failures are logged but do NOT abort the server start, + * because a stale mod is better than no start. + */ + +$panelRoot = defined('PANEL_ROOT') ? PANEL_ROOT : realpath(__DIR__ . '/../../..'); +if ($panelRoot === false) { + $panelRoot = __DIR__ . '/../../..'; +} +chdir($panelRoot); + +if (!is_file('includes/config.inc.php')) { + fwrite(STDERR, "[ERROR] Cannot locate includes/config.inc.php.\n"); + exit(0); // don't block server start +} + +require_once 'includes/config.inc.php'; +require_once 'includes/database.php'; +require_once 'includes/database_mysqli.php'; +require_once 'includes/lib_remote.php'; + +if (!isset($db_host, $db_user, $db_pass, $db_name)) { + fwrite(STDERR, "[ERROR] Database configuration not set.\n"); + exit(0); +} + +$db = new OGPDatabaseMySQL(); +$connResult = $db->connect($db_host, $db_user, $db_pass, $db_name, $table_prefix ?? 'gsp_', $db_port ?? null); +if ($connResult !== true) { + fwrite(STDERR, "[ERROR] DB connect failed: {$connResult}\n"); + exit(0); +} + +require_once __DIR__ . '/lib/WorkshopRepository.php'; +require_once __DIR__ . '/lib/WorkshopInstaller.php'; +require_once __DIR__ . '/lib/WorkshopPreStart.php'; + +$opts = getopt('', ['home-id:', 'help']); + +if (isset($opts['help']) || !isset($opts['home-id'])) { + echo "Usage: php prestart_sync.php --home-id=\n"; + exit(0); +} + +$homeId = (int)$opts['home-id']; +if ($homeId <= 0) { + fwrite(STDERR, "[ERROR] --home-id must be a positive integer.\n"); + exit(0); +} + +$home = $db->getGameHome($homeId); +if (!is_array($home)) { + fwrite(STDERR, "[WARN] Home {$homeId} not found – skipping pre-start sync.\n"); + exit(0); +} + +$repo = new WorkshopRepository($db); +$installer = new WorkshopInstaller($repo); +$preStart = new WorkshopPreStart($repo, $installer); + +$result = $preStart->syncModsForHome($home); + +echo sprintf( + "[PRE-START] home=%d synced=%d skipped=%d failed=%d\n", + $homeId, + $result['synced'], + $result['skipped'], + $result['failed'] +); + +foreach ((array)($result['log'] ?? []) as $line) { + echo " {$line}\n"; +} + +// Always exit 0 – don't block server start due to Workshop sync issues +exit(0); diff --git a/modules/steam_workshop/views/admin/profile_form.php b/modules/steam_workshop/views/admin/profile_form.php new file mode 100644 index 00000000..768f0d6a --- /dev/null +++ b/modules/steam_workshop/views/admin/profile_form.php @@ -0,0 +1,152 @@ + 0 && $profile !== null; +$heading = $isEdit + ? sprintf($lang['profile_heading_edit'] ?? 'Edit Workshop Profile: %s', htmlspecialchars($profile['game_name'] ?? '')) + : ($lang['profile_heading_create'] ?? 'Create Workshop Profile'); + +$v = static function (string $key, array $profile, string $default = ''): string { + return htmlspecialchars((string)($profile[$key] ?? $default), ENT_QUOTES); +}; + +$osList = ['linux' => 'Linux', 'windows' => 'Windows']; +$currentOs = array_filter(explode(',', (string)($profile['supported_os'] ?? 'linux'))); +$methodList = ['rsync' => 'rsync (Linux)', 'robocopy' => 'robocopy (Windows)', 'custom_script' => 'custom_script']; +$curMethod = (string)($profile['copy_method'] ?? 'rsync'); + +$tplVarNote = $lang['profile_template_vars'] ?? 'Available: {home_id} {agent_id} {workshop_app_id} {mod_id} {mod_title} {mod_folder} {steamcmd_path} {server_path} {install_path} {cache_path}'; +?> +
+

+

+ +
+ + + + +
+ +
+ + + +
+ +
+ + $osLabel): ?> + + +
+
+ + +
+ + + + + + + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ +
+ + + + +
+
+
diff --git a/modules/steam_workshop/views/admin/profiles.php b/modules/steam_workshop/views/admin/profiles.php new file mode 100644 index 00000000..90b09f16 --- /dev/null +++ b/modules/steam_workshop/views/admin/profiles.php @@ -0,0 +1,73 @@ + +
+
+

+

+ + + +
+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
App IDOS
+ + + + + + + + + +
+ + + +
+
+ + +
+

+ ← + + +

+
diff --git a/modules/steam_workshop/views/user_workshop_index.php b/modules/steam_workshop/views/user_workshop_index.php new file mode 100644 index 00000000..eecd274e --- /dev/null +++ b/modules/steam_workshop/views/user_workshop_index.php @@ -0,0 +1,65 @@ + +
+

+ + +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
diff --git a/modules/steam_workshop/views/user_workshop_mods.php b/modules/steam_workshop/views/user_workshop_mods.php new file mode 100644 index 00000000..31f44976 --- /dev/null +++ b/modules/steam_workshop/views/user_workshop_mods.php @@ -0,0 +1,220 @@ + +
+

+

+ + +
+

+
+ + + +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + +
+
+
+ + + + +
+
+ +
+ + + + +
+ +
+ + + + +
+
+ + + + +

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + +

+
+ + +
+ + +
+
+ + + $lang['mod_picker_action_add'] ?? 'Add', + 'remove' => $lang['mod_picker_action_remove'] ?? 'Remove', + 'loading' => $lang['mod_picker_status_loading'] ?? 'Searching…', + 'error' => $lang['mod_picker_status_error'] ?? 'Search failed.', + 'empty' => $lang['mod_picker_results_empty'] ?? 'No results.', + 'query' => $lang['mod_picker_status_need_query'] ?? 'Enter a query.', + 'sync' => $lang['mod_picker_toggle_label'] ?? 'Sync', + ]; + ?> +
$lv): ?>data-lang-="" > +
+
+
+ +
+
+ + + + + + + + + +
+
+
+ + +
+ + diff --git a/modules/steam_workshop/workshop_admin.php b/modules/steam_workshop/workshop_admin.php index 846122de..5380cde3 100644 --- a/modules/steam_workshop/workshop_admin.php +++ b/modules/steam_workshop/workshop_admin.php @@ -2,12 +2,30 @@ declare(strict_types=1); require_once __DIR__ . '/controllers/AdminWorkshopController.php'; +require_once __DIR__ . '/controllers/WorkshopProfileController.php'; function exec_ogp_module(): void { global $db; echo '

' . get_lang('steam_workshop') . '

'; + + // Route to the DB-driven profile manager when requested + $swAction = $_GET['sw_action'] ?? ''; + $profileActions = ['profiles', 'profile_form']; + $postAction = $_POST['sw_action'] ?? ''; + $profilePostActions = ['profile_save', 'profile_delete']; + + if (in_array($swAction, $profileActions, true) || in_array($postAction, $profilePostActions, true)) { + $controller = new WorkshopProfileController($db); + $controller->handle(); + return; + } + + // Default: legacy XML adapter manager + tab link to profiles + echo '

' + . (function_exists('get_lang') ? get_lang('nav_workshop_profiles') : 'Workshop Profiles') + . '

'; + $controller = new AdminWorkshopController($db); $controller->handle(); } -?> \ No newline at end of file From fd860963d10a54dbc3b68ade0935e89edb0c15db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:06:05 +0000 Subject: [PATCH 2/2] fix: address code review feedback - Fix toggle/load_order handlers to use page-reload (not JSON) responses - Remove dead jsonResponse helper method from WorkshopModController - Fix robocopy exit code detection using ROBOCOPY_EXIT: sentinel (not text parsing) - Fix rsync dry-run change detection using RSYNC_EXIT: sentinel - Remove agentIdFromRemote() stub; pass agentId directly to triggerSteamCmdDownload() logging - Fix 'enabled' checkbox default in profile_form to use ($profile['enabled'] ?? 1) - Add missing error_toggle_failed / error_order_failed lang strings Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/dbeebd0e-e7a5-469d-8a8c-e63193d1ebb0 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .../controllers/WorkshopModController.php | 33 ++++++---- modules/steam_workshop/lang/en_US.php | 2 + .../steam_workshop/lib/WorkshopInstaller.php | 64 ++++++++++--------- .../views/admin/profile_form.php | 2 +- .../views/user_workshop_mods.php | 4 +- 5 files changed, 59 insertions(+), 46 deletions(-) diff --git a/modules/steam_workshop/controllers/WorkshopModController.php b/modules/steam_workshop/controllers/WorkshopModController.php index 8139b6fc..68f7ae59 100644 --- a/modules/steam_workshop/controllers/WorkshopModController.php +++ b/modules/steam_workshop/controllers/WorkshopModController.php @@ -233,18 +233,25 @@ class WorkshopModController $enabled = !empty($_POST['enabled']); if ($homeId <= 0 || $workshopId === '') { - $this->jsonResponse(['ok' => false, 'error' => 'Missing parameters.']); + print_failure($this->lang['error_missing_params'] ?? 'Missing parameters.'); + $this->handleIndex($userId, $isAdmin); return; } $home = $this->getHome($homeId, $userId, $isAdmin); if ($home === null) { - $this->jsonResponse(['ok' => false, 'error' => 'Access denied.']); + print_failure($this->lang['error_home_not_found'] ?? 'Server not found.'); + $this->handleIndex($userId, $isAdmin); return; } $ok = $this->repo->toggleMod($homeId, $workshopId, $enabled); - $this->jsonResponse(['ok' => $ok]); + if (!$ok) { + print_failure($this->lang['error_toggle_failed'] ?? 'Failed to update mod status.'); + } + + $_GET['home_id'] = $homeId; + $this->handleModsPage($userId, $isAdmin); } private function handleLoadOrder(int $userId, bool $isAdmin): void @@ -254,18 +261,25 @@ class WorkshopModController $order = (int)($_POST['load_order'] ?? 0); if ($homeId <= 0 || $workshopId === '') { - $this->jsonResponse(['ok' => false, 'error' => 'Missing parameters.']); + print_failure($this->lang['error_missing_params'] ?? 'Missing parameters.'); + $this->handleIndex($userId, $isAdmin); return; } $home = $this->getHome($homeId, $userId, $isAdmin); if ($home === null) { - $this->jsonResponse(['ok' => false, 'error' => 'Access denied.']); + print_failure($this->lang['error_home_not_found'] ?? 'Server not found.'); + $this->handleIndex($userId, $isAdmin); return; } $ok = $this->repo->updateLoadOrder($homeId, $workshopId, $order); - $this->jsonResponse(['ok' => $ok]); + if (!$ok) { + print_failure($this->lang['error_order_failed'] ?? 'Failed to update load order.'); + } + + $_GET['home_id'] = $homeId; + $this->handleModsPage($userId, $isAdmin); } private function handleSync(int $userId, bool $isAdmin): void @@ -360,13 +374,6 @@ class WorkshopModController require __DIR__ . '/../views/' . $view . '.php'; } - /** @param array $data */ - private function jsonResponse(array $data): void - { - header('Content-Type: application/json'); - echo json_encode($data); - } - private function loadLang(): array { $file = __DIR__ . '/../lang/en_US.php'; diff --git a/modules/steam_workshop/lang/en_US.php b/modules/steam_workshop/lang/en_US.php index 47ea24b4..db527900 100644 --- a/modules/steam_workshop/lang/en_US.php +++ b/modules/steam_workshop/lang/en_US.php @@ -192,5 +192,7 @@ return [ 'error_missing_params' => 'Missing required parameters.', 'error_no_profile' => 'No Workshop profile configured for this game.', 'error_mod_not_found' => 'Mod or profile not found.', + 'error_toggle_failed' => 'Failed to update mod status.', + 'error_order_failed' => 'Failed to update load order.', ]; diff --git a/modules/steam_workshop/lib/WorkshopInstaller.php b/modules/steam_workshop/lib/WorkshopInstaller.php index c0c5784e..df065446 100644 --- a/modules/steam_workshop/lib/WorkshopInstaller.php +++ b/modules/steam_workshop/lib/WorkshopInstaller.php @@ -94,7 +94,7 @@ class WorkshopInstaller if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') { $log[] = 'Cache MISS – triggering SteamCMD download on agent.'; $downloadResult = $this->triggerSteamCmdDownload( - $remote, $appId, $workshopId, $steamCmdPath, $cachePath, $log + $remote, $agentId, $appId, $workshopId, $steamCmdPath, $cachePath, $log ); if (!$downloadResult) { @@ -273,6 +273,7 @@ class WorkshopInstaller */ private function triggerSteamCmdDownload( object $remote, + int $agentId, string $appId, string $workshopId, string $steamCmdPath, @@ -292,13 +293,13 @@ class WorkshopInstaller ]); $log[] = "SteamCMD start: {$cmd}"; - $this->writeLog("STEAMCMD START agent={$this->agentIdFromRemote($remote)} app={$appId} mod={$workshopId}"); + $this->writeLog("STEAMCMD START agent={$agentId} app={$appId} mod={$workshopId}"); $output = $remote->exec($cmd); if ($output === null) { $log[] = 'SteamCMD: no response from agent (command may still be running).'; - $this->writeLog("STEAMCMD NO_RESPONSE app={$appId} mod={$workshopId}"); + $this->writeLog("STEAMCMD NO_RESPONSE agent={$agentId} app={$appId} mod={$workshopId}"); // Treat as unknown – check file existence } else { $log[] = 'SteamCMD output: ' . substr((string)$output, 0, 500); @@ -307,11 +308,11 @@ class WorkshopInstaller // Verify the download succeeded by checking for the cache path on the agent $exists = $remote->rfile_exists($cachePath); if ($exists === 1) { - $this->writeLog("STEAMCMD SUCCESS app={$appId} mod={$workshopId} path={$cachePath}"); + $this->writeLog("STEAMCMD SUCCESS agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}"); return true; } - $this->writeLog("STEAMCMD FAILURE app={$appId} mod={$workshopId} path={$cachePath}"); + $this->writeLog("STEAMCMD FAILURE agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}"); return false; } @@ -332,27 +333,33 @@ class WorkshopInstaller $log[] = "Pre-start compare: cache={$cachePath} dest={$installPath} method={$copyMethod}"; if ($copyMethod === 'rsync') { + // Dry-run: any output lines (beyond the exit sentinel) mean changes exist $cmd = sprintf( - 'rsync -rcn --delete %s %s 2>/dev/null; echo "EXIT:$?"', + 'rsync -rcn --delete %s %s 2>/dev/null; echo "RSYNC_EXIT:$?"', escapeshellarg(rtrim($cachePath, '/') . '/'), escapeshellarg(rtrim($installPath, '/') . '/') ); - $out = (string)$remote->exec($cmd); - // If rsync dry-run produces file list output, changes exist - $hasChanges = preg_match('/\S/', preg_replace('/EXIT:\d+\s*$/', '', $out) ?? '') === 1; - return $hasChanges; + $out = (string)$remote->exec($cmd); + // Strip the exit line, then check for any non-whitespace output + $body = preg_replace('/RSYNC_EXIT:\d+\s*$/', '', $out) ?? ''; + return preg_match('/\S/', $body) === 1; } if ($copyMethod === 'robocopy') { - // Robocopy /L = list only, /MIR = mirror, /NJH /NJS = no headers + // List-only mode: robocopy exit code 0 = no differences, 1+ = changes or errors. + // Embed the exit code in output so we can read it back via exec(). $cmd = sprintf( - 'robocopy /L /MIR /NJH /NJS %s %s', + 'robocopy /L /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"', escapeshellarg($cachePath), escapeshellarg($installPath) ); - $out = (string)$remote->exec($cmd); - // Exit code 0 = no changes, 1+ = changes - return trim($out) !== '' && !preg_match('/\bNo new\b/i', $out); + $out = (string)$remote->exec($cmd); + if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) { + // 0 = no change; 1–7 = informational (changes found); 8+ = error + return (int)$m[1] !== 0; + } + // If we cannot determine, assume sync is needed + return true; } // custom_script: always sync @@ -392,7 +399,7 @@ class WorkshopInstaller ); } elseif ($copyMethod === 'robocopy') { $cmd = sprintf( - 'robocopy /MIR /NJH /NJS %s %s; echo "ROBOCOPY EXIT:$LASTEXITCODE"', + 'robocopy /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"', escapeshellarg($cachePath), escapeshellarg($installPath) ); @@ -419,17 +426,20 @@ class WorkshopInstaller $out = (string)$remote->exec($cmd); $log[] = 'Sync output: ' . substr($out, 0, 500); - // Check exit code hint embedded in output - if (preg_match('/EXIT:(\d+)/', $out, $m)) { - $code = (int)$m[1]; - // robocopy exit codes 0..7 are success/info, 8+ are errors - if ($copyMethod === 'robocopy') { - $ok = $code < 8; + // Determine success from embedded exit code sentinel + if ($copyMethod === 'robocopy') { + if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) { + // 0–7 = success/informational; 8+ = error + $ok = (int)$m[1] < 8; } else { - $ok = $code === 0; + $ok = true; // assume success if no code extracted } } else { - $ok = true; // assume success if no code + if (preg_match('/EXIT:(\d+)/', $out, $m)) { + $ok = (int)$m[1] === 0; + } else { + $ok = true; + } } if ($ok) { @@ -471,12 +481,6 @@ class WorkshopInstaller return 'linux'; } - private function agentIdFromRemote(object $remote): string - { - // OGPRemoteLibrary stores host/port; use reflection-free fallback - return 'unknown'; - } - private function writeLog(string $message): void { $file = $this->logDir . '/workshop_install.log'; diff --git a/modules/steam_workshop/views/admin/profile_form.php b/modules/steam_workshop/views/admin/profile_form.php index 768f0d6a..f05244d0 100644 --- a/modules/steam_workshop/views/admin/profile_form.php +++ b/modules/steam_workshop/views/admin/profile_form.php @@ -135,7 +135,7 @@ $tplVarNote = $lang['profile_template_vars'] ?? 'Available: {home_id} {agent_id diff --git a/modules/steam_workshop/views/user_workshop_mods.php b/modules/steam_workshop/views/user_workshop_mods.php index 31f44976..04afcd3e 100644 --- a/modules/steam_workshop/views/user_workshop_mods.php +++ b/modules/steam_workshop/views/user_workshop_mods.php @@ -203,14 +203,14 @@ $baseAction = '?m=steam_workshop&p=main';