417 lines
13 KiB
PHP
417 lines
13 KiB
PHP
<?php
|
|
/*
|
|
* Game-agnostic Steam Workshop support for GSP
|
|
* This library centralizes config/state handling so UI pages can stay thin.
|
|
*/
|
|
|
|
if (!defined('IN_OGP')) {
|
|
exit('Direct access not permitted');
|
|
}
|
|
|
|
class WorkshopConfigStore
|
|
{
|
|
private $file;
|
|
|
|
public function __construct($file)
|
|
{
|
|
$this->file = $file;
|
|
$dir = dirname($file);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0775, true);
|
|
}
|
|
}
|
|
|
|
public function all()
|
|
{
|
|
$data = $this->readFile();
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
public function get($homeId)
|
|
{
|
|
$all = $this->all();
|
|
return isset($all[$homeId]) ? $all[$homeId] : null;
|
|
}
|
|
|
|
public function put($homeId, array $config)
|
|
{
|
|
$all = $this->all();
|
|
$all[$homeId] = $config;
|
|
$this->writeFile($all);
|
|
}
|
|
|
|
public function delete($homeId)
|
|
{
|
|
$all = $this->all();
|
|
if (isset($all[$homeId])) {
|
|
unset($all[$homeId]);
|
|
$this->writeFile($all);
|
|
}
|
|
}
|
|
|
|
private function readFile()
|
|
{
|
|
if (!is_file($this->file)) {
|
|
return [];
|
|
}
|
|
$fh = fopen($this->file, 'c+');
|
|
if ($fh === false) {
|
|
return [];
|
|
}
|
|
flock($fh, LOCK_SH);
|
|
$raw = stream_get_contents($fh);
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
$data = json_decode($raw, true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
private function writeFile(array $data)
|
|
{
|
|
$fh = fopen($this->file, 'c+');
|
|
if ($fh === false) {
|
|
return;
|
|
}
|
|
flock($fh, LOCK_EX);
|
|
ftruncate($fh, 0);
|
|
rewind($fh);
|
|
fwrite($fh, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
fflush($fh);
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
}
|
|
}
|
|
|
|
class WorkshopStateStore
|
|
{
|
|
private $dir;
|
|
|
|
public function __construct($dir)
|
|
{
|
|
$this->dir = rtrim($dir, '/');
|
|
if (!is_dir($this->dir)) {
|
|
mkdir($this->dir, 0775, true);
|
|
}
|
|
}
|
|
|
|
public function get($homeId)
|
|
{
|
|
$path = $this->statePath($homeId);
|
|
if (!is_file($path)) {
|
|
return ['items' => [], 'last_sync' => null, 'last_status' => null];
|
|
}
|
|
$raw = file_get_contents($path);
|
|
$data = json_decode($raw, true);
|
|
if (!is_array($data)) {
|
|
return ['items' => [], 'last_sync' => null, 'last_status' => null];
|
|
}
|
|
if (!isset($data['items'])) {
|
|
$data['items'] = [];
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
public function put($homeId, array $state)
|
|
{
|
|
$path = $this->statePath($homeId);
|
|
$payload = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
file_put_contents($path, $payload);
|
|
}
|
|
|
|
public function updateItem($homeId, $itemId, array $itemState)
|
|
{
|
|
$state = $this->get($homeId);
|
|
$state['items'][$itemId] = $itemState;
|
|
$this->put($homeId, $state);
|
|
}
|
|
|
|
private function statePath($homeId)
|
|
{
|
|
return $this->dir . '/state_' . $homeId . '.json';
|
|
}
|
|
}
|
|
|
|
class WorkshopLock
|
|
{
|
|
private $fh;
|
|
|
|
public function __construct($lockPath)
|
|
{
|
|
$dir = dirname($lockPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0775, true);
|
|
}
|
|
$this->fh = fopen($lockPath, 'c');
|
|
}
|
|
|
|
public function acquire()
|
|
{
|
|
if (!$this->fh) {
|
|
return false;
|
|
}
|
|
return flock($this->fh, LOCK_EX | LOCK_NB);
|
|
}
|
|
|
|
public function release()
|
|
{
|
|
if ($this->fh) {
|
|
flock($this->fh, LOCK_UN);
|
|
}
|
|
}
|
|
}
|
|
|
|
class WorkshopResolver
|
|
{
|
|
public function resolveItems(array $config)
|
|
{
|
|
$items = array_map('trim', isset($config['workshop_item_ids']) ? (array)$config['workshop_item_ids'] : []);
|
|
$collections = array_map('trim', isset($config['collection_ids']) ? (array)$config['collection_ids'] : []);
|
|
|
|
if (!empty($collections)) {
|
|
foreach ($collections as $collectionId) {
|
|
$expanded = $this->expandCollection($collectionId);
|
|
$items = array_merge($items, $expanded);
|
|
}
|
|
}
|
|
|
|
$items = array_values(array_unique(array_filter($items, 'strlen')));
|
|
return $items;
|
|
}
|
|
|
|
private function expandCollection($collectionId)
|
|
{
|
|
$payload = http_build_query([
|
|
'collectioncount' => 1,
|
|
'publishedfileids[0]' => $collectionId,
|
|
]);
|
|
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => "Content-Type: application/x-www-form-urlencoded",
|
|
'content' => $payload,
|
|
'timeout' => 10,
|
|
],
|
|
]);
|
|
|
|
$json = @file_get_contents('https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/', false, $context);
|
|
if ($json === false) {
|
|
return [];
|
|
}
|
|
$data = json_decode($json, true);
|
|
if (!isset($data['response']['collectiondetails'][0]['children'])) {
|
|
return [];
|
|
}
|
|
$children = $data['response']['collectiondetails'][0]['children'];
|
|
$ids = [];
|
|
foreach ($children as $child) {
|
|
if (isset($child['publishedfileid'])) {
|
|
$ids[] = $child['publishedfileid'];
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
public function fetchItemDetails(array $itemIds)
|
|
{
|
|
if (empty($itemIds)) {
|
|
return [];
|
|
}
|
|
$payload = ['itemcount' => count($itemIds)];
|
|
$index = 0;
|
|
foreach ($itemIds as $id) {
|
|
$payload['publishedfileids[' . $index . ']'] = $id;
|
|
$index++;
|
|
}
|
|
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => "Content-Type: application/x-www-form-urlencoded",
|
|
'content' => http_build_query($payload),
|
|
'timeout' => 10,
|
|
],
|
|
]);
|
|
|
|
$json = @file_get_contents('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', false, $context);
|
|
if ($json === false) {
|
|
return [];
|
|
}
|
|
$data = json_decode($json, true);
|
|
if (!isset($data['response']['publishedfiledetails'])) {
|
|
return [];
|
|
}
|
|
$details = [];
|
|
foreach ($data['response']['publishedfiledetails'] as $item) {
|
|
$details[$item['publishedfileid']] = $item;
|
|
}
|
|
return $details;
|
|
}
|
|
}
|
|
|
|
class WorkshopSyncResult
|
|
{
|
|
public $success;
|
|
public $message;
|
|
public $updatedItems;
|
|
public $skippedItems;
|
|
|
|
public function __construct($success, $message, array $updatedItems = [], array $skippedItems = [])
|
|
{
|
|
$this->success = $success;
|
|
$this->message = $message;
|
|
$this->updatedItems = $updatedItems;
|
|
$this->skippedItems = $skippedItems;
|
|
}
|
|
}
|
|
|
|
class WorkshopSyncService
|
|
{
|
|
private $remote;
|
|
private $homeCfg;
|
|
private $configStore;
|
|
private $stateStore;
|
|
private $resolver;
|
|
|
|
public function __construct($remote, array $homeCfg, WorkshopConfigStore $configStore, WorkshopStateStore $stateStore, WorkshopResolver $resolver)
|
|
{
|
|
$this->remote = $remote;
|
|
$this->homeCfg = $homeCfg;
|
|
$this->configStore = $configStore;
|
|
$this->stateStore = $stateStore;
|
|
$this->resolver = $resolver;
|
|
}
|
|
|
|
public function sync($homeId)
|
|
{
|
|
$config = $this->configStore->get($homeId);
|
|
if (!$config) {
|
|
return new WorkshopSyncResult(false, 'No workshop configuration found for this server.');
|
|
}
|
|
|
|
$itemIds = $this->resolver->resolveItems($config);
|
|
if (empty($itemIds)) {
|
|
return new WorkshopSyncResult(true, 'No workshop items configured.', [], []);
|
|
}
|
|
|
|
$details = $this->resolver->fetchItemDetails($itemIds);
|
|
$state = $this->stateStore->get($homeId);
|
|
$needsUpdate = [];
|
|
$skipped = [];
|
|
|
|
foreach ($itemIds as $id) {
|
|
$remoteUpdated = isset($details[$id]['time_updated']) ? (int)$details[$id]['time_updated'] : null;
|
|
$local = isset($state['items'][$id]) ? $state['items'][$id] : null;
|
|
$localSeen = $local && isset($local['last_seen_manifest_id']) ? (int)$local['last_seen_manifest_id'] : null;
|
|
if ($remoteUpdated !== null && $localSeen !== null && $remoteUpdated <= $localSeen) {
|
|
$skipped[] = $id;
|
|
continue;
|
|
}
|
|
$needsUpdate[] = $id;
|
|
}
|
|
|
|
if (empty($needsUpdate)) {
|
|
$state['last_sync'] = time();
|
|
$state['last_status'] = 'up-to-date';
|
|
$this->stateStore->put($homeId, $state);
|
|
return new WorkshopSyncResult(true, 'All workshop items are up-to-date.', [], $skipped);
|
|
}
|
|
|
|
$modsFullPath = $this->modsPath($config);
|
|
$paths = $this->buildPaths($config);
|
|
$modNamesList = implode(',', $needsUpdate);
|
|
|
|
$result = $this->remote->steam_workshop(
|
|
$homeId,
|
|
$modsFullPath,
|
|
$config['workshop_app_id'],
|
|
implode(',', $needsUpdate),
|
|
$config['regex'],
|
|
(int)$config['mods_backreference_index'],
|
|
$config['variable'],
|
|
$config['place_after'],
|
|
$config['mod_string'],
|
|
$config['string_separator'],
|
|
$paths['config_file_path'],
|
|
$config['post_install'],
|
|
$modNamesList,
|
|
$config['anonymous_login'] ? '1' : '0',
|
|
$config['steam_username'],
|
|
$config['steam_password'],
|
|
$config['download_method'],
|
|
'',
|
|
''
|
|
);
|
|
|
|
if ($result !== 1) {
|
|
return new WorkshopSyncResult(false, 'Workshop sync failed to start (agent error code ' . $result . ').');
|
|
}
|
|
|
|
$now = time();
|
|
foreach ($needsUpdate as $id) {
|
|
$state['items'][$id] = [
|
|
'last_seen_manifest_id' => isset($details[$id]['time_updated']) ? (int)$details[$id]['time_updated'] : $now,
|
|
'last_downloaded_at' => $now,
|
|
'local_path' => $modsFullPath,
|
|
'deploy_path' => $paths['deploy_destination'],
|
|
'last_error' => null,
|
|
];
|
|
}
|
|
$state['last_sync'] = $now;
|
|
$state['last_status'] = 'started';
|
|
$this->stateStore->put($homeId, $state);
|
|
|
|
return new WorkshopSyncResult(true, 'Workshop sync started.', $needsUpdate, $skipped);
|
|
}
|
|
|
|
private function modsPath(array $config)
|
|
{
|
|
if (!empty($config['staging_path'])) {
|
|
return clean_path($config['staging_path']);
|
|
}
|
|
return clean_path($this->homeCfg['home_path'] . '/workshop');
|
|
}
|
|
|
|
private function buildPaths(array $config)
|
|
{
|
|
$serverRoot = rtrim($this->homeCfg['home_path'], '/');
|
|
$deployDest = !empty($config['deploy_destination']) ? clean_path($config['deploy_destination']) : ($serverRoot . '/mods');
|
|
$configPath = !empty($config['filepath']) ? clean_path($config['filepath']) : ($serverRoot . '/config.cfg');
|
|
return [
|
|
'deploy_destination' => $deployDest,
|
|
'config_file_path' => $configPath,
|
|
];
|
|
}
|
|
}
|
|
|
|
function workshop_build_default_config($homeCfg, $modXml, $settings)
|
|
{
|
|
$modsPath = ($modXml && isset($modXml->mods_path)) ? (string)$modXml->mods_path : 'mods';
|
|
$configNode = $modXml && isset($modXml->config) ? $modXml->config : null;
|
|
|
|
return [
|
|
'workshop_app_id' => ($modXml && isset($modXml->workshop_id)) ? (string)$modXml->workshop_id : '',
|
|
'download_method' => ($modXml && isset($modXml->download_method)) ? (string)$modXml->download_method : 'steamcmd',
|
|
'deploy_mode' => 'copy',
|
|
'deploy_destination' => clean_path($homeCfg['home_path'] . '/' . $modsPath),
|
|
'staging_path' => clean_path($homeCfg['home_path'] . '/' . $modsPath),
|
|
'mods_path' => clean_path($homeCfg['home_path'] . '/' . $modsPath),
|
|
'regex' => ($configNode && isset($configNode->regex)) ? (string)$configNode->regex : '',
|
|
'mods_backreference_index' => ($configNode && isset($configNode->mods_backreference_index)) ? (int)$configNode->mods_backreference_index : 1,
|
|
'variable' => ($configNode && isset($configNode->variable)) ? (string)$configNode->variable : '',
|
|
'place_after' => ($configNode && isset($configNode->place_after)) ? (string)$configNode->place_after : '',
|
|
'mod_string' => ($configNode && isset($configNode->mod_string)) ? (string)$configNode->mod_string : '%workshop_mod_id%',
|
|
'string_separator' => ($configNode && isset($configNode->string_separator)) ? (string)$configNode->string_separator : ';',
|
|
'filepath' => ($configNode && isset($configNode->filepath)) ? clean_path($homeCfg['home_path'] . '/' . $configNode->filepath) : '',
|
|
'post_install' => ($modXml && isset($modXml->post_install)) ? (string)$modXml->post_install : '',
|
|
'uninstall' => ($modXml && isset($modXml->uninstall)) ? (string)$modXml->uninstall : '',
|
|
'anonymous_login' => ($modXml && isset($modXml->anonymous_login)) ? ((string)$modXml->anonymous_login === '1') : true,
|
|
'steam_username' => isset($settings['steam_user']) ? $settings['steam_user'] : '',
|
|
'steam_password' => isset($settings['steam_pass']) ? $settings['steam_pass'] : '',
|
|
'workshop_item_ids' => [],
|
|
'collection_ids' => [],
|
|
'check_on_start' => true,
|
|
'periodic_check_minutes' => null,
|
|
'apply_updates' => 'on_start_only',
|
|
];
|
|
}
|