Merge pull request #73 from GameServerPanel/copilot/rewrite-steam-workshop-module
This commit is contained in:
commit
4db784a84a
17 changed files with 3020 additions and 8 deletions
388
modules/steam_workshop/controllers/WorkshopModController.php
Normal file
388
modules/steam_workshop/controllers/WorkshopModController.php
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* OGP / GSP – Steam Workshop
|
||||
* WorkshopModController: user-facing mod management per game server.
|
||||
*
|
||||
* Actions (via ?action=...):
|
||||
* mods → show installed mods + available cached mods for a server
|
||||
* install → install a mod (POST: home_id, workshop_id)
|
||||
* remove → remove a mod (POST: home_id, workshop_id)
|
||||
* toggle → enable/disable (POST: home_id, workshop_id, enabled)
|
||||
* load_order → update load order (POST: home_id, workshop_id, load_order)
|
||||
* sync → sync now (POST: home_id, workshop_id)
|
||||
* search → JSON search (GET: home_id, q) – reuses SteamWorkshopService
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/WorkshopRepository.php';
|
||||
require_once __DIR__ . '/../lib/WorkshopInstaller.php';
|
||||
require_once __DIR__ . '/../lib/SteamWorkshopService.php';
|
||||
|
||||
class WorkshopModController
|
||||
{
|
||||
private WorkshopRepository $repo;
|
||||
private WorkshopInstaller $installer;
|
||||
private SteamWorkshopService $searchService;
|
||||
private array $lang;
|
||||
|
||||
public function __construct(OGPDatabase $db)
|
||||
{
|
||||
$this->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 '<link rel="stylesheet" type="text/css" href="modules/steam_workshop/steam_workshop.css" />';
|
||||
echo '<script src="modules/steam_workshop/steam_workshop.js" defer></script>';
|
||||
|
||||
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 === '') {
|
||||
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;
|
||||
}
|
||||
|
||||
$ok = $this->repo->toggleMod($homeId, $workshopId, $enabled);
|
||||
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
|
||||
{
|
||||
$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 === '') {
|
||||
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;
|
||||
}
|
||||
|
||||
$ok = $this->repo->updateLoadOrder($homeId, $workshopId, $order);
|
||||
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
|
||||
{
|
||||
$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<int,array<string,mixed>> */
|
||||
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';
|
||||
}
|
||||
|
||||
private function loadLang(): array
|
||||
{
|
||||
$file = __DIR__ . '/../lang/en_US.php';
|
||||
if (is_file($file)) {
|
||||
$strings = require $file;
|
||||
if (is_array($strings)) {
|
||||
return $strings;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
221
modules/steam_workshop/controllers/WorkshopProfileController.php
Normal file
221
modules/steam_workshop/controllers/WorkshopProfileController.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* OGP / GSP – Steam Workshop
|
||||
* WorkshopProfileController: admin CRUD for Workshop game profiles
|
||||
* (gsp_workshop_game_profiles table).
|
||||
*
|
||||
* Routed via workshop_admin.php:
|
||||
* ?m=steam_workshop&p=workshop_admin&sw_action=profiles → list
|
||||
* ?m=steam_workshop&p=workshop_admin&sw_action=profile_form → create/edit
|
||||
* POST sw_action=profile_save → save
|
||||
* POST sw_action=profile_delete → delete
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/WorkshopRepository.php';
|
||||
|
||||
class WorkshopProfileController
|
||||
{
|
||||
private WorkshopRepository $repo;
|
||||
private array $lang;
|
||||
|
||||
public function __construct(OGPDatabase $db)
|
||||
{
|
||||
$this->repo = new WorkshopRepository($db);
|
||||
$this->lang = $this->loadLang();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
echo '<link rel="stylesheet" type="text/css" href="modules/steam_workshop/steam_workshop.css" />';
|
||||
|
||||
$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<string,mixed> $post
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
178
modules/steam_workshop/cron_update.php
Normal file
178
modules/steam_workshop/cron_update.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* OGP / GSP – Steam Workshop cron update script
|
||||
*
|
||||
* Usage:
|
||||
* php modules/steam_workshop/cron_update.php --all
|
||||
* php modules/steam_workshop/cron_update.php --agent-id=<ID>
|
||||
* php modules/steam_workshop/cron_update.php --home-id=<ID>
|
||||
* php modules/steam_workshop/cron_update.php --profile-id=<ID>
|
||||
* php modules/steam_workshop/cron_update.php --workshop-id=<WID> --agent-id=<AID> --app-id=<APPID>
|
||||
*
|
||||
* 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 <<<HELP
|
||||
GSP Steam Workshop – cron cache updater
|
||||
|
||||
Usage:
|
||||
php cron_update.php --all
|
||||
php cron_update.php --agent-id=<ID>
|
||||
php cron_update.php --home-id=<ID>
|
||||
php cron_update.php --profile-id=<ID>
|
||||
php cron_update.php --workshop-id=<WID> --agent-id=<AID> --app-id=<APPID>
|
||||
|
||||
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);
|
||||
|
|
@ -103,4 +103,96 @@ 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.',
|
||||
'error_toggle_failed' => 'Failed to update mod status.',
|
||||
'error_order_failed' => 'Failed to update load order.',
|
||||
];
|
||||
|
||||
|
|
|
|||
501
modules/steam_workshop/lib/WorkshopInstaller.php
Normal file
501
modules/steam_workshop/lib/WorkshopInstaller.php
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
<?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, $agentId, $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,
|
||||
int $agentId,
|
||||
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={$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 agent={$agentId} 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 agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}");
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->writeLog("STEAMCMD FAILURE agent={$agentId} 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') {
|
||||
// Dry-run: any output lines (beyond the exit sentinel) mean changes exist
|
||||
$cmd = sprintf(
|
||||
'rsync -rcn --delete %s %s 2>/dev/null; echo "RSYNC_EXIT:$?"',
|
||||
escapeshellarg(rtrim($cachePath, '/') . '/'),
|
||||
escapeshellarg(rtrim($installPath, '/') . '/')
|
||||
);
|
||||
$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') {
|
||||
// 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; echo "ROBOCOPY_EXIT:$LASTEXITCODE"',
|
||||
escapeshellarg($cachePath),
|
||||
escapeshellarg($installPath)
|
||||
);
|
||||
$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
|
||||
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);
|
||||
|
||||
// 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 = true; // assume success if no code extracted
|
||||
}
|
||||
} else {
|
||||
if (preg_match('/EXIT:(\d+)/', $out, $m)) {
|
||||
$ok = (int)$m[1] === 0;
|
||||
} else {
|
||||
$ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
118
modules/steam_workshop/lib/WorkshopPreStart.php
Normal file
118
modules/steam_workshop/lib/WorkshopPreStart.php
Normal 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);
|
||||
}
|
||||
}
|
||||
438
modules/steam_workshop/lib/WorkshopRepository.php
Normal file
438
modules/steam_workshop/lib/WorkshopRepository.php
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
314
modules/steam_workshop/lib/WorkshopUpdater.php
Normal file
314
modules/steam_workshop/lib/WorkshopUpdater.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,41 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* OGP - Open Game Panel
|
||||
* Steam Workshop module entrypoint.
|
||||
* OGP / GSP – Steam Workshop module entrypoint.
|
||||
* Routes to either the new DB-driven WorkshopModController or the
|
||||
* legacy SteamWorkshopController, depending on the action requested.
|
||||
*/
|
||||
require_once __DIR__ . '/controllers/SteamWorkshopController.php';
|
||||
require_once __DIR__ . '/controllers/WorkshopModController.php';
|
||||
|
||||
function exec_ogp_module(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action !== 'search') {
|
||||
echo '<h2>' . get_lang('steam_workshop') . '</h2>';
|
||||
$postAction = $_POST['ws_action'] ?? '';
|
||||
|
||||
// JSON search endpoint – no heading
|
||||
if ($action === 'search') {
|
||||
$controller = new SteamWorkshopController($db);
|
||||
$controller->handle();
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<h2>' . get_lang('steam_workshop') . '</h2>';
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
65
modules/steam_workshop/migrations/001_workshop_tables.sql
Normal file
65
modules/steam_workshop/migrations/001_workshop_tables.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
?>
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
91
modules/steam_workshop/prestart_sync.php
Normal file
91
modules/steam_workshop/prestart_sync.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* OGP / GSP – Steam Workshop pre-start sync helper
|
||||
*
|
||||
* Called from the game XML <pre_start> tag or a server pre-start hook:
|
||||
* php modules/steam_workshop/prestart_sync.php --home-id=<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=<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);
|
||||
152
modules/steam_workshop/views/admin/profile_form.php
Normal file
152
modules/steam_workshop/views/admin/profile_form.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/** @var array $lang */
|
||||
/** @var array|null $profile existing row when editing, null when creating */
|
||||
/** @var int $profileId */
|
||||
|
||||
$isEdit = $profileId > 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}';
|
||||
?>
|
||||
<div class="sw-admin sw-profile-form">
|
||||
<h3><?php echo $heading; ?></h3>
|
||||
<p><a href="?m=steam_workshop&p=workshop_admin&sw_action=profiles">← <?php echo htmlspecialchars($lang['profile_back_list'] ?? 'Back to profiles'); ?></a></p>
|
||||
|
||||
<form method="post" action="?m=steam_workshop&p=workshop_admin" class="sw-form">
|
||||
<input type="hidden" name="sw_action" value="profile_save">
|
||||
<input type="hidden" name="profile_id" value="<?php echo $profileId; ?>">
|
||||
|
||||
<!-- Basic info -->
|
||||
<fieldset>
|
||||
<legend><?php echo htmlspecialchars($lang['profile_section_basic'] ?? 'Basic info'); ?></legend>
|
||||
<div class="sw-form__grid">
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['label_game_key'] ?? 'Game key'); ?> <em>*</em>
|
||||
<input type="text" name="game_key" value="<?php echo $v('game_key', $profile ?? []); ?>"
|
||||
pattern="[A-Za-z0-9_\-.]+" required maxlength="100"
|
||||
<?php echo $isEdit ? 'readonly' : ''; ?>>
|
||||
</label>
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_game_name'] ?? 'Game name'); ?> <em>*</em>
|
||||
<input type="text" name="game_name" value="<?php echo $v('game_name', $profile ?? []); ?>"
|
||||
required maxlength="255">
|
||||
</label>
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['label_adapter_app_id'] ?? 'Workshop App ID'); ?> <em>*</em>
|
||||
<input type="text" name="workshop_app_id"
|
||||
value="<?php echo $v('workshop_app_id', $profile ?? []); ?>"
|
||||
pattern="[0-9]+" required maxlength="32">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="sw-form__os-group">
|
||||
<legend><?php echo htmlspecialchars($lang['profile_label_os'] ?? 'Supported OS'); ?></legend>
|
||||
<?php foreach ($osList as $osVal => $osLabel): ?>
|
||||
<label class="sw-checkbox">
|
||||
<input type="checkbox" name="supported_os[]" value="<?php echo $osVal; ?>"
|
||||
<?php echo in_array($osVal, $currentOs, true) ? 'checked' : ''; ?>>
|
||||
<span><?php echo htmlspecialchars($osLabel); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<!-- Paths / templates -->
|
||||
<fieldset>
|
||||
<legend><?php echo htmlspecialchars($lang['profile_section_paths'] ?? 'Paths & templates'); ?></legend>
|
||||
<small class="sw-hint"><?php echo htmlspecialchars($tplVarNote); ?></small>
|
||||
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_cache_path'] ?? 'Cache path template'); ?> <em>*</em>
|
||||
<small><?php echo htmlspecialchars($lang['profile_hint_cache_path'] ?? 'Where SteamCMD downloads mods on the agent. E.g. {steamcmd_path}/steamapps/workshop/content/{workshop_app_id}/{mod_id}'); ?></small>
|
||||
<input type="text" name="cache_path_template"
|
||||
value="<?php echo $v('cache_path_template', $profile ?? []); ?>" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_install_path'] ?? 'Install path template'); ?> <em>*</em>
|
||||
<small><?php echo htmlspecialchars($lang['profile_hint_install_path'] ?? 'Server-side mod directory. E.g. {server_path}/mods/{mod_folder}'); ?></small>
|
||||
<input type="text" name="install_path_template"
|
||||
value="<?php echo $v('install_path_template', $profile ?? []); ?>" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_folder_name'] ?? 'Mod folder name template'); ?>
|
||||
<small><?php echo htmlspecialchars($lang['profile_hint_folder_name'] ?? 'Folder name for each mod. Default: @{mod_id}'); ?></small>
|
||||
<input type="text" name="folder_name_template"
|
||||
value="<?php echo $v('folder_name_template', $profile ?? [], '@{mod_id}'); ?>">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Copy method -->
|
||||
<fieldset>
|
||||
<legend><?php echo htmlspecialchars($lang['profile_section_copy'] ?? 'Copy / sync method'); ?></legend>
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_copy_method'] ?? 'Copy method'); ?>
|
||||
<select name="copy_method">
|
||||
<?php foreach ($methodList as $mVal => $mLabel): ?>
|
||||
<option value="<?php echo $mVal; ?>" <?php echo $curMethod === $mVal ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($mLabel); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_install_script'] ?? 'Custom install script (admin-defined only, optional)'); ?>
|
||||
<small><?php echo htmlspecialchars($lang['profile_hint_install_script'] ?? 'Only used when copy method is custom_script. Template variables are replaced before execution.'); ?></small>
|
||||
<textarea name="install_script" rows="4"><?php echo $v('install_script', $profile ?? []); ?></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Config / launch params -->
|
||||
<fieldset>
|
||||
<legend><?php echo htmlspecialchars($lang['profile_section_config'] ?? 'Config & launch parameters'); ?></legend>
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_config_tpl'] ?? 'Config file template (optional)'); ?>
|
||||
<textarea name="config_file_template" rows="4"><?php echo $v('config_file_template', $profile ?? []); ?></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['profile_label_launch_tpl'] ?? 'Launch parameter template (optional)'); ?>
|
||||
<input type="text" name="launch_param_template"
|
||||
value="<?php echo $v('launch_param_template', $profile ?? []); ?>">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Flags -->
|
||||
<fieldset>
|
||||
<legend><?php echo htmlspecialchars($lang['profile_section_flags'] ?? 'Flags'); ?></legend>
|
||||
<label class="sw-checkbox">
|
||||
<input type="checkbox" name="requires_restart" value="1"
|
||||
<?php echo !empty($profile['requires_restart']) ? 'checked' : ''; ?>>
|
||||
<span><?php echo htmlspecialchars($lang['profile_label_requires_restart'] ?? 'Restart required after mod install/update'); ?></span>
|
||||
</label>
|
||||
<label class="sw-checkbox">
|
||||
<input type="checkbox" name="enabled" value="1"
|
||||
<?php echo ($profile['enabled'] ?? 1) ? 'checked' : ''; ?>>
|
||||
<span><?php echo htmlspecialchars($lang['profile_label_enabled'] ?? 'Profile enabled'); ?></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="sw-form__actions">
|
||||
<button class="btn primary" type="submit">
|
||||
<?php echo htmlspecialchars($lang['button_save'] ?? 'Save'); ?>
|
||||
</button>
|
||||
<a class="btn" href="?m=steam_workshop&p=workshop_admin&sw_action=profiles">
|
||||
<?php echo htmlspecialchars($lang['button_cancel'] ?? 'Cancel'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
73
modules/steam_workshop/views/admin/profiles.php
Normal file
73
modules/steam_workshop/views/admin/profiles.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/** @var array $lang */
|
||||
/** @var array[] $profiles */
|
||||
?>
|
||||
<div class="sw-admin sw-profiles">
|
||||
<div class="sw-admin__intro">
|
||||
<h3><?php echo htmlspecialchars($lang['profile_heading_list'] ?? 'Workshop Game Profiles'); ?></h3>
|
||||
<p><?php echo htmlspecialchars($lang['profile_intro'] ?? 'One profile per supported game. Each profile drives mod install and caching behaviour.'); ?></p>
|
||||
<a class="btn primary" href="?m=steam_workshop&p=workshop_admin&sw_action=profile_form">
|
||||
<?php echo htmlspecialchars($lang['profile_btn_create'] ?? 'Create Profile'); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($profiles)): ?>
|
||||
<p class="sw-empty"><?php echo htmlspecialchars($lang['profile_list_empty'] ?? 'No Workshop profiles defined yet.'); ?></p>
|
||||
<?php else: ?>
|
||||
<table class="table sw-profiles__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo htmlspecialchars($lang['profile_col_game'] ?? 'Game'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['profile_col_key'] ?? 'Game Key'); ?></th>
|
||||
<th>App ID</th>
|
||||
<th>OS</th>
|
||||
<th><?php echo htmlspecialchars($lang['profile_col_method'] ?? 'Copy Method'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['profile_col_restart'] ?? 'Restart?'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['profile_col_status'] ?? 'Status'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Actions'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ((array)$profiles as $profile): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($profile['game_name']); ?></td>
|
||||
<td><code><?php echo htmlspecialchars($profile['game_key']); ?></code></td>
|
||||
<td><?php echo htmlspecialchars($profile['workshop_app_id']); ?></td>
|
||||
<td><?php echo htmlspecialchars($profile['supported_os']); ?></td>
|
||||
<td><?php echo htmlspecialchars($profile['copy_method']); ?></td>
|
||||
<td><?php echo $profile['requires_restart'] ? '✔' : '✘'; ?></td>
|
||||
<td>
|
||||
<?php if ($profile['enabled']): ?>
|
||||
<span class="sw-badge sw-badge--enabled"><?php echo htmlspecialchars($lang['status_enabled'] ?? 'Enabled'); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="sw-badge sw-badge--disabled"><?php echo htmlspecialchars($lang['status_disabled'] ?? 'Disabled'); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="sw-actions">
|
||||
<a class="btn secondary"
|
||||
href="?m=steam_workshop&p=workshop_admin&sw_action=profile_form&profile_id=<?php echo (int)$profile['id']; ?>">
|
||||
<?php echo htmlspecialchars($lang['button_edit'] ?? 'Edit'); ?>
|
||||
</a>
|
||||
<form method="post" action="?m=steam_workshop&p=workshop_admin" class="sw-inline-delete">
|
||||
<input type="hidden" name="sw_action" value="profile_delete">
|
||||
<input type="hidden" name="profile_id" value="<?php echo (int)$profile['id']; ?>">
|
||||
<button type="submit" class="btn danger"
|
||||
onclick="return confirm('<?php echo htmlspecialchars($lang['profile_confirm_delete'] ?? 'Delete this Workshop profile?'); ?>')">
|
||||
<?php echo htmlspecialchars($lang['button_delete_adapter'] ?? 'Delete'); ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<hr>
|
||||
<p>
|
||||
<a href="?m=steam_workshop&p=workshop_admin">←
|
||||
<?php echo htmlspecialchars($lang['profile_back_adapters'] ?? 'Back to adapter management'); ?>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
65
modules/steam_workshop/views/user_workshop_index.php
Normal file
65
modules/steam_workshop/views/user_workshop_index.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/** @var array $lang */
|
||||
/** @var array[] $records each: {home, profile, mods} */
|
||||
/** @var bool $isAdmin */
|
||||
?>
|
||||
<div class="sw-user sw-ws-index">
|
||||
<h3><?php echo htmlspecialchars($lang['user_workshop_heading'] ?? 'Steam Workshop'); ?></h3>
|
||||
|
||||
<?php if (empty($records)): ?>
|
||||
<p class="sw-empty">
|
||||
<?php echo htmlspecialchars($isAdmin ? ($lang['empty_state_admin'] ?? 'No game homes assigned.') : ($lang['empty_state_user'] ?? 'No servers available.')); ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="table sw-ws-index__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo htmlspecialchars($lang['col_server'] ?? 'Server'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_game'] ?? 'Game'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_mods_count'] ?? 'Installed mods'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_profile'] ?? 'Profile'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Actions'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ((array)$records as $record): ?>
|
||||
<?php
|
||||
$home = $record['home'];
|
||||
$profile = $record['profile'];
|
||||
$mods = $record['mods'];
|
||||
$homeId = (int)($home['home_id'] ?? 0);
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($home['home_name'] ?? ('#' . $homeId)); ?></td>
|
||||
<td><?php echo htmlspecialchars($home['game_key'] ?? ''); ?></td>
|
||||
<td><?php echo count((array)$mods); ?></td>
|
||||
<td>
|
||||
<?php if ($profile !== null): ?>
|
||||
<span class="sw-badge sw-badge--enabled">
|
||||
<?php echo htmlspecialchars($profile['game_name']); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="sw-badge sw-badge--disabled">
|
||||
<?php echo htmlspecialchars($lang['no_profile'] ?? 'No profile'); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="sw-actions">
|
||||
<?php if ($profile !== null): ?>
|
||||
<a class="btn secondary"
|
||||
href="?m=steam_workshop&p=main&action=mods&home_id=<?php echo $homeId; ?>">
|
||||
<?php echo htmlspecialchars($lang['btn_manage_mods'] ?? 'Manage Mods'); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="sw-hint">
|
||||
<?php echo htmlspecialchars($lang['hint_no_profile'] ?? 'Ask an admin to create a Workshop profile for this game.'); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
220
modules/steam_workshop/views/user_workshop_mods.php
Normal file
220
modules/steam_workshop/views/user_workshop_mods.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
/** @var array $lang */
|
||||
/** @var array $home */
|
||||
/** @var int $homeId */
|
||||
/** @var array|null $profile */
|
||||
/** @var string|null $appId */
|
||||
/** @var array[] $installedMods */
|
||||
/** @var array[] $availableMods */
|
||||
/** @var bool $isAdmin */
|
||||
|
||||
$homeName = htmlspecialchars($home['home_name'] ?? ('#' . $homeId));
|
||||
$baseAction = '?m=steam_workshop&p=main';
|
||||
?>
|
||||
<div class="sw-user sw-ws-mods">
|
||||
<p><a href="<?php echo $baseAction; ?>">← <?php echo htmlspecialchars($lang['button_cancel'] ?? 'Back'); ?></a></p>
|
||||
<h3><?php echo sprintf(htmlspecialchars($lang['user_workshop_server_heading'] ?? 'Workshop Mods – %s'), $homeName); ?></h3>
|
||||
|
||||
<?php if ($profile === null): ?>
|
||||
<div class="sw-notice">
|
||||
<p><?php echo htmlspecialchars($lang['no_profile_notice'] ?? 'No Workshop profile is configured for this game. An administrator needs to create one first.'); ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- Installed mods table -->
|
||||
<h4><?php echo htmlspecialchars($lang['heading_installed_mods'] ?? 'Installed Mods'); ?></h4>
|
||||
<?php if (empty($installedMods)): ?>
|
||||
<p class="sw-empty"><?php echo htmlspecialchars($lang['no_installed_mods'] ?? 'No mods installed yet.'); ?></p>
|
||||
<?php else: ?>
|
||||
<table class="table sw-ws-mods__table" id="sw-installed-<?php echo $homeId; ?>">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_id'] ?? 'Workshop ID'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_title'] ?? 'Title'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['mods_header_enabled'] ?? 'Enabled'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_load_order'] ?? 'Load order'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Actions'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ((array)$installedMods as $mod): ?>
|
||||
<?php $wid = htmlspecialchars($mod['workshop_id']); ?>
|
||||
<tr data-workshop-id="<?php echo $wid; ?>">
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=<?php echo $wid; ?>"
|
||||
target="_blank" rel="noopener"><?php echo $wid; ?></a>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($mod['title'] ?? $mod['workshop_id']); ?></td>
|
||||
<td>
|
||||
<form method="post" action="<?php echo $baseAction; ?>" class="sw-toggle-form">
|
||||
<input type="hidden" name="ws_action" value="toggle">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<input type="hidden" name="workshop_id" value="<?php echo $wid; ?>">
|
||||
<label class="sw-toggle">
|
||||
<input type="checkbox" name="enabled" value="1"
|
||||
class="js-ws-toggle"
|
||||
<?php echo !empty($mod['enabled']) ? 'checked' : ''; ?>>
|
||||
<span><?php echo !empty($mod['enabled']) ? htmlspecialchars($lang['status_enabled'] ?? 'Yes') : htmlspecialchars($lang['status_disabled'] ?? 'No'); ?></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="<?php echo $baseAction; ?>" class="sw-order-form">
|
||||
<input type="hidden" name="ws_action" value="load_order">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<input type="hidden" name="workshop_id" value="<?php echo $wid; ?>">
|
||||
<input type="number" name="load_order"
|
||||
value="<?php echo (int)$mod['load_order']; ?>"
|
||||
min="0" max="9999" class="sw-order-input js-ws-order"
|
||||
style="width:5em">
|
||||
</form>
|
||||
</td>
|
||||
<td class="sw-actions">
|
||||
<!-- Sync now -->
|
||||
<form method="post" action="<?php echo $baseAction; ?>" class="sw-inline">
|
||||
<input type="hidden" name="ws_action" value="sync">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<input type="hidden" name="workshop_id" value="<?php echo $wid; ?>">
|
||||
<button type="submit" class="btn secondary">
|
||||
<?php echo htmlspecialchars($lang['btn_sync_now'] ?? 'Sync now'); ?>
|
||||
</button>
|
||||
</form>
|
||||
<!-- Remove -->
|
||||
<form method="post" action="<?php echo $baseAction; ?>" class="sw-inline">
|
||||
<input type="hidden" name="ws_action" value="remove">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<input type="hidden" name="workshop_id" value="<?php echo $wid; ?>">
|
||||
<button type="submit" class="btn danger"
|
||||
onclick="return confirm('<?php echo htmlspecialchars($lang['confirm_remove_mod'] ?? 'Remove this mod?'); ?>')">
|
||||
<?php echo htmlspecialchars($lang['btn_remove_mod'] ?? 'Remove'); ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Install from cache -->
|
||||
<?php if (!empty($availableMods)): ?>
|
||||
<h4><?php echo htmlspecialchars($lang['heading_cached_mods'] ?? 'Available Cached Mods (this agent)'); ?></h4>
|
||||
<table class="table sw-ws-mods__cache-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_id'] ?? 'Workshop ID'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_title'] ?? 'Title'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_cache_status'] ?? 'Cache status'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Actions'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ((array)$availableMods as $cached): ?>
|
||||
<?php $cid = htmlspecialchars($cached['workshop_id']); ?>
|
||||
<tr>
|
||||
<td><?php echo $cid; ?></td>
|
||||
<td><?php echo htmlspecialchars($cached['title'] ?? $cached['workshop_id']); ?></td>
|
||||
<td><?php echo htmlspecialchars($cached['status']); ?></td>
|
||||
<td>
|
||||
<form method="post" action="<?php echo $baseAction; ?>">
|
||||
<input type="hidden" name="ws_action" value="install">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<input type="hidden" name="workshop_id" value="<?php echo $cid; ?>">
|
||||
<button type="submit" class="btn secondary">
|
||||
<?php echo htmlspecialchars($lang['btn_install_mod'] ?? 'Install'); ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Search + install by Workshop ID -->
|
||||
<h4><?php echo htmlspecialchars($lang['heading_install_mod'] ?? 'Install Mod by Workshop ID'); ?></h4>
|
||||
<form method="post" action="<?php echo $baseAction; ?>" class="sw-form sw-install-form">
|
||||
<input type="hidden" name="ws_action" value="install">
|
||||
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>">
|
||||
<div class="sw-form__row">
|
||||
<label>
|
||||
<?php echo htmlspecialchars($lang['label_workshop_id_input'] ?? 'Workshop ID'); ?>
|
||||
<input type="text" name="workshop_id" pattern="[0-9]+" required
|
||||
placeholder="<?php echo htmlspecialchars($lang['placeholder_workshop_id'] ?? 'e.g. 1234567890'); ?>">
|
||||
</label>
|
||||
<button type="submit" class="btn primary">
|
||||
<?php echo htmlspecialchars($lang['btn_install_mod'] ?? 'Install'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Steam Workshop search widget (reuse existing JS picker) -->
|
||||
<?php
|
||||
$scriptPath = (string)($_SERVER['PHP_SELF'] ?? '/index.php');
|
||||
$searchEndpoint = sprintf('%s?m=steam_workshop&p=main&action=search&home_id=%d', $scriptPath, $homeId);
|
||||
$langAttrs = [
|
||||
'add' => $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',
|
||||
];
|
||||
?>
|
||||
<div class="sw-picker" id="sw-picker-ws-<?php echo $homeId; ?>"
|
||||
data-endpoint="<?php echo htmlspecialchars($searchEndpoint, ENT_QUOTES); ?>"
|
||||
data-detail-base="https://steamcommunity.com/sharedfiles/filedetails/?id="
|
||||
data-install-action="<?php echo $baseAction; ?>"
|
||||
data-home-id="<?php echo $homeId; ?>"
|
||||
<?php foreach ((array)$langAttrs as $lk => $lv): ?>data-lang-<?php echo $lk; ?>="<?php echo htmlspecialchars($lv, ENT_QUOTES); ?>" <?php endforeach; ?>>
|
||||
<div class="sw-picker__header">
|
||||
<h5><?php echo htmlspecialchars($lang['mod_picker_heading'] ?? 'Search Steam Workshop'); ?></h5>
|
||||
</div>
|
||||
<div class="sw-picker__search js-sw-search-form" role="search">
|
||||
<label>
|
||||
<span><?php echo htmlspecialchars($lang['mod_picker_search_label'] ?? 'Search'); ?></span>
|
||||
<input type="text" class="sw-picker__search-input js-sw-search-input"
|
||||
placeholder="<?php echo htmlspecialchars($lang['mod_picker_search_placeholder'] ?? 'ID or keyword'); ?>">
|
||||
</label>
|
||||
<button type="button" class="btn secondary js-sw-search-button">
|
||||
<?php echo htmlspecialchars($lang['mod_picker_search_button'] ?? 'Search'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sw-picker__status js-sw-picker-status" role="status" aria-live="polite"></div>
|
||||
<div class="sw-picker__results">
|
||||
<table class="sw-picker__results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_id'] ?? 'ID'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['col_mod_title'] ?? 'Title'); ?></th>
|
||||
<th><?php echo htmlspecialchars($lang['admin_col_actions'] ?? 'Action'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="js-sw-results"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; // profile !== null ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* Simple toggle / order auto-submit for the mods table */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Toggle enable/disable: submit the parent form immediately on change
|
||||
document.querySelectorAll('.js-ws-toggle').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
cb.closest('form').submit();
|
||||
});
|
||||
});
|
||||
|
||||
// Load order: submit on change (blur triggers faster than enter on number inputs)
|
||||
document.querySelectorAll('.js-ws-order').forEach(function (inp) {
|
||||
inp.addEventListener('change', function () {
|
||||
inp.closest('form').submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -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 '<h2>' . get_lang('steam_workshop') . '</h2>';
|
||||
|
||||
// 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 '<p><a class="btn secondary" href="?m=steam_workshop&p=workshop_admin&sw_action=profiles">'
|
||||
. (function_exists('get_lang') ? get_lang('nav_workshop_profiles') : 'Workshop Profiles')
|
||||
. '</a></p>';
|
||||
|
||||
$controller = new AdminWorkshopController($db);
|
||||
$controller->handle();
|
||||
}
|
||||
?>
|
||||
Loading…
Add table
Add a link
Reference in a new issue