lastChecked = now()->format('H:i'); $channel = $this->channel(); $installed = (string) config('clusev.version'); // Force a fresh remote lookup — this is the explicit "check for updates" action. $latest = $this->resolveLatestTag($channel, forceRemote: true); if ($latest === null) { $this->updateState = 'current'; $this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]); } elseif ($this->isNewer($latest, $installed)) { $this->updateState = 'update'; $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); } else { $this->updateState = 'current'; $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); } } /** * Operator-triggered update: write the update sentinel a host watcher reacts to by running * update.sh (git pull + idempotent re-install). Re-checks that a newer release than the * installed one is actually published in the channel (never fires blindly), is per-user * throttled (auto-expiring — never a control-plane lockout) and audited. The container never * runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we * say so and do not await a result. */ public function requestUpdate(DeploymentService $deployment): void { $channel = $this->channel(); $installed = (string) config('clusev.version'); $latest = $this->resolveLatestTag($channel, forceRemote: true); // Reflect the freshly-checked state, then refuse if there is nothing newer to apply. $this->lastChecked = now()->format('H:i'); if ($latest === null || ! $this->isNewer($latest, $installed)) { $this->updateState = 'current'; $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); $this->dispatch('notify', message: __('versions.update_not_available'), level: 'error'); return; } $this->updateState = 'update'; $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); $key = 'update-request:'.Auth::id(); if (RateLimiter::tooManyAttempts($key, 3)) { $this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); return; } RateLimiter::hit($key, 600); $deployment->requestUpdate(); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => 'deploy.update_request', 'target' => $installed.' → '.$latest.' ('.$channel.')', 'ip' => request()->ip(), ]); $this->updateRequested = true; $this->dispatch('notify', message: __('versions.update_started')); } /** Resolve the configured channel, clamped to the user-facing set. */ private function channel(): string { $channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable'); return in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; } /** * Newest release tag for the channel. Prefers the public Git host's tag API (the * production image ships NO .git, so a local read would always be empty there), and * falls back to local .git tags for dev. The remote result is cached so a page render * never makes a network call — only the explicit "check for updates" action does, by * passing $forceRemote. Returns null when nothing usable is found anywhere. */ private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string { $key = 'clusev:latest-release:'.$channel; if ($forceRemote) { $remote = $this->fetchRemoteLatestTag($channel); if ($remote !== null) { Cache::put($key, $remote, now()->addMinutes(30)); return $remote; } // Remote unreachable — fall through to whatever we last cached / can read locally. } $cached = Cache::get($key); if (is_string($cached) && $cached !== '') { return $cached; } return $this->localLatestTag($channel); } /** * Latest tag from the public Git host's API (Gitea: /api/v1/repos///tags). * Anonymous + read-only (the repo is public) — no token ever leaves the panel. Returns * null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully. */ private 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; } } /** * Newest release tag from local .git, without needing the git binary. Reads loose refs * under refs/tags plus packed-refs. Empty in the production image (no .git) — that is why * the remote API is the primary source above. */ private function localLatestTag(string $channel): ?string { $root = base_path('.git'); $tags = []; $dir = $root.'/refs/tags'; if (is_dir($dir)) { foreach (scandir($dir) ?: [] as $entry) { if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) { $tags[] = $entry; } } } foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) { if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) { $tags[] = $m[1]; } } return $this->newestVersion($tags, $channel); } /** * Pick the newest semantic version from a list of tag names, scoped to the channel: * 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-…), so a * beta user is still offered a newer stable. Leading "v" is normalised off the result. * * @param array $tagNames */ private 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]; } /** Is the given tag a newer semantic version than the installed one? */ private function isNewer(string $tag, string $installed): bool { return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>'); } /** Resolve the current commit from .git without needing the git binary. */ private function build(): array { $root = base_path('.git'); $head = @file_get_contents($root.'/HEAD'); if ($head === false) { return ['sha' => null, 'branch' => $this->channel()]; } $head = trim($head); if (! str_starts_with($head, 'ref: ')) { return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()]; } $ref = substr($head, 5); $branch = preg_replace('#^refs/heads/#', '', $ref); $sha = @file_get_contents($root.'/'.$ref); if ($sha === false) { // packed-refs fallback foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) { if (str_ends_with(trim($line), ' '.$ref)) { $sha = explode(' ', trim($line))[0]; break; } } } return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch]; } /** * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its * version, optional date, and the changes grouped by Keep-a-Changelog * subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest * first (Unreleased on top). * * @return array>}> */ private function releases(): array { $path = base_path('CHANGELOG.md'); if (! is_file($path)) { return []; } $releases = []; $current = null; $group = null; foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) { // Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]" if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) { if ($current !== null) { $releases[] = $current; } $version = trim($m[1]); $unreleased = strcasecmp($version, 'Unreleased') === 0; $current = [ 'version' => $version, 'date' => isset($m[2]) ? trim($m[2]) : null, 'unreleased' => $unreleased, 'groups' => [], ]; $group = null; continue; } if ($current === null) { continue; } // Subsection header: "### Hinzugefügt" if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) { $group = trim($m[1]); $current['groups'][$group] ??= []; continue; } // Bullet line under the active subsection (handles wrapped continuations). if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) { $current['groups'][$group][] = trim($m[1]); continue; } if ($group !== null && $current['groups'][$group] !== [] && preg_match('/^\s{2,}(\S.*)$/', $line, $m)) { // Continuation of the previous bullet (Keep-a-Changelog wrapping). $last = array_key_last($current['groups'][$group]); $current['groups'][$group][$last] .= ' '.trim($m[1]); } } if ($current !== null) { $releases[] = $current; } // Drop empty nodes (e.g. a bare Unreleased) and link-reference tails. return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== [])); } public function render() { $releases = $this->releases(); // Passive (no network): cached remote result if a check has run, else local .git. $latestTag = $this->resolveLatestTag($this->channel()); return view('livewire.versions.index', [ 'version' => config('clusev.version'), 'channel' => $this->channel(), 'repository' => config('clusev.repository'), 'license' => config('clusev.license'), 'build' => $this->build(), 'releases' => $releases, 'latestTag' => $latestTag, ])->title(__('versions.title')); } }