clusev/app/Services/ReleaseChecker.php

135 lines
4.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Resolves the newest published release tag for the configured channel and tells whether the
* installed version is behind it. Shared by the Versions page (which forces a fresh check on the
* explicit action) and by the global sidebar update badge + the scheduled refresh (which read /
* warm a short-lived cache so no page render or sidebar ever makes a network call).
*
* Anonymous + read-only against the public Git host's tag API — no token ever leaves the panel.
*/
class ReleaseChecker
{
private const CHANNELS = ['stable', 'beta'];
/** Cache the resolved tag long enough that the scheduled refresh keeps it continuously warm. */
private const TTL_MINUTES = 60;
private function cacheKey(string $channel): string
{
return 'clusev:latest-release:'.$channel;
}
public function channel(): string
{
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
}
/** Last resolved latest tag (no network). Null when unknown. */
public function cachedLatest(?string $channel = null): ?string
{
$value = Cache::get($this->cacheKey($channel ?? $this->channel()));
return is_string($value) && $value !== '' ? $value : null;
}
/**
* Whether a newer release than the installed version is known from the cache (no network) — the
* single source for the sidebar badge. A stale cache below the installed version never counts.
*/
public function updateAvailable(): bool
{
$latest = $this->cachedLatest();
if ($latest === null) {
return false;
}
return version_compare(ltrim($latest, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '>');
}
/**
* Force a fresh remote lookup, cache it, and return the newest tag for the channel. Null on any
* failure (offline, non-2xx, malformed) so callers degrade gracefully.
*/
public function refresh(?string $channel = null): ?string
{
$channel ??= $this->channel();
$tag = $this->fetchRemoteLatestTag($channel);
if ($tag !== null) {
Cache::put($this->cacheKey($channel), $tag, now()->addMinutes(self::TTL_MINUTES));
}
return $tag;
}
/**
* Newest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/tags).
* Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure.
*/
public function fetchRemoteLatestTag(string $channel): ?string
{
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
[, $base, $owner, $name] = $m;
try {
$res = Http::timeout(5)->acceptJson()
->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
if (! $res->successful()) {
return null;
}
$names = array_map(
static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
(array) $res->json(),
);
return $this->newestVersion($names, $channel);
} catch (\Throwable $e) {
report($e);
return null;
}
}
/**
* Highest version among the tag names, honouring the channel (stable rejects prereleases).
* Returns the version without the "v" prefix.
*
* @param array<int, string> $tagNames
*/
public function newestVersion(array $tagNames, string $channel): ?string
{
$pattern = $channel === 'beta'
? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
: '/^v?\d+\.\d+\.\d+$/';
$versions = [];
foreach (array_unique($tagNames) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '' && preg_match($pattern, $tag)) {
$versions[] = ltrim($tag, 'vV');
}
}
if ($versions === []) {
return null;
}
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
return $versions[0];
}
}