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]; } }