From cb4dfe04b5cef205a1c02bc8109f87bebf52aecf Mon Sep 17 00:00:00 2001 From: Divarion-D Date: Fri, 3 Jul 2026 20:10:41 +0300 Subject: [PATCH] feat(modules): resolve module update sources + weekly update-check cron ModuleUpdateChecker resolves the latest available version per module by source: git (via GitHubReleases), url (version.json), platform (best-effort), bundled (no-op). ModuleUpdatesCronJob (cron:module_updates) records available_version into config/modules.php; migration 008 registers it to run weekly. Network-free unit tests cover the routing. --- src/Cli/CronJobs/ModuleUpdatesCronJob.php | 72 ++++++++++++ src/Core/Module/ModuleUpdateChecker.php | 105 ++++++++++++++++++ .../008_add_module_updates_cron.sql | 8 ++ tests/Unit/ModuleUpdateCheckerTest.php | 61 ++++++++++ 4 files changed, 246 insertions(+) create mode 100644 src/Cli/CronJobs/ModuleUpdatesCronJob.php create mode 100644 src/Core/Module/ModuleUpdateChecker.php create mode 100644 src/migrations/008_add_module_updates_cron.sql create mode 100644 tests/Unit/ModuleUpdateCheckerTest.php diff --git a/src/Cli/CronJobs/ModuleUpdatesCronJob.php b/src/Cli/CronJobs/ModuleUpdatesCronJob.php new file mode 100644 index 00000000..380d9a4c --- /dev/null +++ b/src/Cli/CronJobs/ModuleUpdatesCronJob.php @@ -0,0 +1,72 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ModuleUpdatesCronJob implements CommandInterface { + + public function getName(): string { + return 'cron:module_updates'; + } + + public function getDescription(): string { + return 'Cron: check module update availability from their declared sources'; + } + + public function execute(array $rArgs): int { + register_shutdown_function(function () { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + }); + + $rManager = new ModuleManager(container: ServiceContainer::getInstance()); + $rChecker = new ModuleUpdateChecker(); + + foreach ($rManager->listModules() as $rModule) { + // Only installed modules — the check compares against installed_version. + if (($rModule['installed_version'] ?? '') === '') { + continue; + } + + $rInstalled = (string) $rModule['installed_version']; + $rLatest = $rChecker->latestAvailable($rModule); + + if ($rLatest !== null && version_compare($rLatest, $rInstalled, '>')) { + $rManager->recordAvailableVersion($rModule['name'], $rLatest); + echo '[UPDATE] ' . $rModule['name'] . ': ' . $rInstalled . ' -> ' . $rLatest . "\n"; + } else { + // Nothing newer / not checkable — clear any stale flag. + $rManager->recordAvailableVersion($rModule['name'], null); + } + } + + return 0; + } +} diff --git a/src/Core/Module/ModuleUpdateChecker.php b/src/Core/Module/ModuleUpdateChecker.php new file mode 100644 index 00000000..c2be453b --- /dev/null +++ b/src/Core/Module/ModuleUpdateChecker.php @@ -0,0 +1,105 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ModuleUpdateChecker { + + /** + * Latest available version for a module (a listModules() row), or null. + * + * @param array $module Module row with keys `update`, `version`, `installed_version`. + * @return string|null Version string, or null if nothing newer / not checkable. + */ + public function latestAvailable(array $module): ?string { + $update = is_array($module['update'] ?? null) ? $module['update'] : []; + $source = (string) ($update['source'] ?? 'bundled'); + $installed = (string) ($module['installed_version'] ?? ''); + + return match ($source) { + 'git' => $this->fromGit($update, $installed), + 'url' => $this->fromUrl($update), + 'platform' => $this->fromPlatform($update), + default => ((string) ($module['version'] ?? '')) ?: null, // bundled + }; + } + + /** GitHub releases of update.repository, newest newer-than-installed (or null). */ + private function fromGit(array $update, string $installed): ?string { + $repo = (string) ($update['repository'] ?? ''); + // https://github.com/OWNER/REPO(.git) | git@github.com:OWNER/REPO.git + if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', $repo, $m)) { + return null; + } + // Map the manifest channel onto GitHubReleases' stable/unstable. + $channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true) + ? 'unstable' + : 'stable'; + try { + $gh = new GitHubReleases($m[1], $m[2], $channel); + return $gh->getLatestVersion($installed !== '' ? $installed : '0.0.0'); + } catch (\Throwable $e) { + error_log('ModuleUpdateChecker(git): ' . $e->getMessage()); + return null; + } + } + + /** version.json at a self-hosted https URL: {"version":"1.2.3"}. */ + private function fromUrl(array $update): ?string { + $url = (string) ($update['url'] ?? ''); + if ($url === '' || stripos($url, 'https://') !== 0) { + return null; // https only — SSRF/downgrade guard + } + $data = json_decode($this->httpGet($url), true); + $ver = is_array($data) ? trim((string) ($data['version'] ?? '')) : ''; + return $ver !== '' ? $ver : null; + } + + /** SaaS store latest version — best-effort; skipped if the extension has no such API. */ + private function fromPlatform(array $update): ?string { + if (!class_exists('XC_VM') || !method_exists('XC_VM', 'module_latest')) { + return null; // store resolves "latest approved" at install time + } + try { + $r = \XC_VM::module_latest((string) ($update['slug'] ?? '')); + return is_array($r) && !empty($r['version']) ? (string) $r['version'] : null; + } catch (\Throwable $e) { + return null; + } + } + + /** cURL GET (file_get_contents over https does not work under PHP-FPM here). */ + private function httpGet(string $url): string { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'XC_VM-ModuleUpdateChecker'); + $body = curl_exec($ch); + curl_close($ch); + return is_string($body) ? $body : ''; + } +} diff --git a/src/migrations/008_add_module_updates_cron.sql b/src/migrations/008_add_module_updates_cron.sql new file mode 100644 index 00000000..7dae58e0 --- /dev/null +++ b/src/migrations/008_add_module_updates_cron.sql @@ -0,0 +1,8 @@ +-- Weekly check for module update availability (sources: git / url / platform). +-- Regenerated into the xc_vm crontab as `console.php cron:module_updates` +-- (the crontab table maps `filename` → `cron:`). Read-only: it only +-- records `available_version` in config/modules.php, never downloads/applies files. +-- Runs Tuesdays 04:30 (just after the MaxMind GeoIP job at 04:00). +INSERT INTO `crontab` (`filename`, `time`, `enabled`) + SELECT 'module_updates', '30 4 * * 2', 1 + WHERE NOT EXISTS (SELECT 1 FROM `crontab` WHERE `filename` = 'module_updates'); diff --git a/tests/Unit/ModuleUpdateCheckerTest.php b/tests/Unit/ModuleUpdateCheckerTest.php new file mode 100644 index 00000000..49e43288 --- /dev/null +++ b/tests/Unit/ModuleUpdateCheckerTest.php @@ -0,0 +1,61 @@ +checker = new ModuleUpdateChecker(); + } + + public function testBundledReturnsOnDiskVersion(): void { + $result = $this->checker->latestAvailable([ + 'update' => ['source' => 'bundled'], + 'version' => '1.2.0', + 'installed_version' => '1.0.0', + ]); + $this->assertSame('1.2.0', $result); + } + + public function testAbsentUpdateBlockTreatedAsBundled(): void { + $result = $this->checker->latestAvailable([ + 'version' => '2.0.0', + 'installed_version' => '1.0.0', + ]); + $this->assertSame('2.0.0', $result); + } + + public function testUrlSourceRejectsNonHttps(): void { + $result = $this->checker->latestAvailable([ + 'update' => ['source' => 'url', 'url' => 'http://example.com/version.json'], + 'installed_version' => '1.0.0', + ]); + $this->assertNull($result); // https-only guard, no network + } + + public function testGitSourceWithInvalidRepoReturnsNull(): void { + $result = $this->checker->latestAvailable([ + 'update' => ['source' => 'git', 'repository' => 'not-a-github-url'], + 'installed_version' => '1.0.0', + ]); + $this->assertNull($result); // regex fails before any network call + } + + public function testPlatformWithoutExtensionReturnsNull(): void { + // The xcvm_core extension is not loaded in the test runtime, so the + // platform branch short-circuits to null. + $result = $this->checker->latestAvailable([ + 'update' => ['source' => 'platform', 'slug' => 'watch'], + 'installed_version' => '1.0.0', + ]); + $this->assertNull($result); + } +}