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>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-30 18:01:33 +00:00 committed by GitHub
parent 4ad46c4332
commit 8eff063a93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3007 additions and 8 deletions

View file

@ -0,0 +1,497 @@
<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopInstaller: handles mod download (via agent SteamCMD) and
* copy/sync from agent cache to server install path.
*
* Template variables supported in all paths/scripts:
* {home_id} numeric home id
* {agent_id} numeric remote_server_id
* {workshop_app_id} Steam app id (e.g. 221100)
* {mod_id} Workshop mod id (numeric string)
* {mod_title} mod title (sanitised)
* {steamcmd_path} path to steamcmd.sh / steamcmd.exe on the agent
* {server_path} game server home_path
* {install_path} resolved install path for this mod
* {cache_path} resolved cache path for this mod
*/
require_once __DIR__ . '/WorkshopRepository.php';
class WorkshopInstaller
{
private WorkshopRepository $repo;
private string $logDir;
public function __construct(WorkshopRepository $repo)
{
$this->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<string>}
*/
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<string>}
*/
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<string,string> $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<string,string>
*/
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<string> $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<string> $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<string,string> $vars
* @param list<string> $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<string,string> $vars
* @param list<string> $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,
];
}
}

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopPreStart: syncs updated cached mods into the game server folder
* before the server is launched.
*
* Intended to be called from the game XML <pre_start> 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<string>}
*/
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);
}
}

View file

@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopRepository: database access layer for the three Workshop tables.
*/
class WorkshopRepository
{
private OGPDatabase $db;
private string $prefix;
public function __construct(OGPDatabase $db)
{
$this->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<int,array<string,mixed>> */
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<int,array<string,mixed>> */
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<int,array<string,mixed>> */
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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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}"
);
}
}

View file

@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopUpdater: scheduled / background cache update functions.
*
* Design rules:
* - Do NOT copy into running servers during a scheduled update.
* - Do NOT restart servers automatically.
* - Log every attempt.
* - Group SteamCMD calls by (agent_id, workshop_app_id, workshop_id) to
* avoid redundant downloads when multiple servers share a mod.
*/
require_once __DIR__ . '/WorkshopRepository.php';
require_once __DIR__ . '/WorkshopInstaller.php';
class WorkshopUpdater
{
private WorkshopRepository $repo;
private WorkshopInstaller $installer;
private string $logDir;
private string $logFile;
public function __construct(WorkshopRepository $repo, WorkshopInstaller $installer)
{
$this->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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<string,mixed>
*/
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<string,mixed> */
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);
}
}