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;
}
/**
* Parse the configured repository URL into [base, owner, name], or null when unset/unparseable.
* The single place that understands the repository URL — both the tag API and the raw-file
* (changelog) endpoint derive from it, so a new host is taught in exactly one spot.
*
* @return array{0: string, 1: string, 2: string}|null
*/
private function repoParts(): ?array
{
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
return [$m[1], $m[2], $m[3]]; // base, owner, name
}
/** Is the configured host GitHub? GitHub's API + raw host differ from a self-hosted Gitea. */
private function isGithubHost(string $base): bool
{
return (bool) preg_match('#^https?://(www\.)?github\.com/?$#i', $base);
}
/**
* Newest tag from the host's tag API. Gitea: /api/v1/repos///tags?limit=.
* GitHub: https://api.github.com/repos///tags?per_page= (a User-Agent is mandatory there).
* Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure.
*/
public function fetchRemoteLatestTag(string $channel): ?string
{
$parts = $this->repoParts();
if ($parts === null) {
return null;
}
[$base, $owner, $name] = $parts;
$github = $this->isGithubHost($base);
$url = $github
? "https://api.github.com/repos/{$owner}/{$name}/tags"
: "{$base}/api/v1/repos/{$owner}/{$name}/tags";
try {
$res = Http::timeout(5)->acceptJson()
->withHeaders(['User-Agent' => 'Clusev-Panel']) // GitHub rejects UA-less requests
->get($url, $github ? ['per_page' => 50] : ['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;
}
}
/**
* Fetch CHANGELOG.md at a ref (tag) from the configured host, for the "what's new" preview.
* Gitea: the /raw API with ?ref=. GitHub: https://raw.githubusercontent.com///[/…
* Cached briefly so a page render never repeats the call. Null on any failure (degrade quietly).
*/
public function fetchChangelog(string $ref): ?string
{
$parts = $this->repoParts();
if ($parts === null) {
return null;
}
[$base, $owner, $name] = $parts;
$key = 'clusev:remote-changelog:'.$ref;
$cached = Cache::get($key);
if (is_string($cached)) {
return $cached;
}
try {
$res = $this->isGithubHost($base)
? Http::timeout(5)->withHeaders(['User-Agent' => 'Clusev-Panel'])
->get("https://raw.githubusercontent.com/{$owner}/{$name}/{$ref}/CHANGELOG.md")
: Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]);
if (! $res->successful()) {
return null;
}
$body = $res->body();
Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached
return $body;
} 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 $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];
}
}
]