From ab728efc6f9f3455e93affe3bc7dc347811dabd9 Mon Sep 17 00:00:00 2001 From: boban Date: Wed, 24 Jun 2026 23:51:28 +0200 Subject: [PATCH] fix(versions): dev box lists BOTH its source and the public repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Projekt-card change: the dev/release-control box must list its actual update source (e.g. private Gitea) AND the public repo — the previous commit showed only the source on dev. Replaced the single project link with projectLinks(): the public repo is always listed (validated slug, canonical fallback); the dev box prepends its source when it is a real URL (a garbage/hostless source is dropped, never an empty link). Staging/stable still list the public repo only — no private leak. Codex review: no leak path on non-dev; hardened the dev source against a hostless URL. Verified: 433 tests green (dev-both / non-dev-only / malformed-slug / garbage-source), pint clean, /versions shows both links on the dev box with zero console errors. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Versions/Index.php | 42 +++++++++----- .../views/livewire/versions/index.blade.php | 11 ++-- tests/Feature/VersionUpdateCheckTest.php | 57 ++++++++++++++----- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 621a56f..71989c0 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -564,29 +564,43 @@ class Index extends Component } /** - * The repository shown on the "Projekt" card. Only the dev/release-control box surfaces its - * actual update source (which may be the private Gitea); EVERY other install — staging or - * stable — links the public open-core repo only, so a non-dev server can never expose a private - * source regardless of how its CLUSEV_REPOSITORY happens to be set. (The update SOURCE is still - * config('clusev.repository'); this only governs the displayed project link.) + * Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The + * dev/release-control box ALSO lists its actual update source first (which may be the private + * Gitea) — staging and stable never do, so a non-dev server can never expose a private source + * regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still + * config('clusev.repository'); this only governs the displayed links.) * * release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the * host release bridge, so a staging/stable box never has it on — and even if it did, that box's - * CLUSEV_REPOSITORY is the public repo anyway (it pulls from public). On a non-dev box the link - * is built from the public slug ONLY, and a malformed slug (empty / slashed / a full URL) falls - * back to the canonical public repo — never to a private host. + * CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug + * ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public + * repo — never to a private host. + * + * @return list */ - private function projectUrl(): string + private function projectLinks(): array { - if (config('clusev.release_controls')) { - return (string) config('clusev.repository'); - } $slug = trim((string) config('clusev.public_slug'), '/'); if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) { $slug = 'clusev/clusev'; } + $urls = ['https://github.com/'.$slug]; - return 'https://github.com/'.$slug; + // Dev box only: ALSO surface the actual update source (e.g. the private Gitea), shown first. + // Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather + // than rendered as an empty link — the public repo still shows. + if (config('clusev.release_controls')) { + $source = (string) config('clusev.repository'); + if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) { + array_unshift($urls, $source); + } + } + + return array_map(static fn (string $u): array => [ + 'url' => $u, + 'host' => (string) parse_url($u, PHP_URL_HOST), + 'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'), + ], $urls); } public function render() @@ -628,7 +642,7 @@ class Index extends Component return view('livewire.versions.index', [ 'version' => config('clusev.version'), 'channel' => $this->channel(), - 'repository' => $this->projectUrl(), + 'repositories' => $this->projectLinks(), 'license' => config('clusev.license'), 'build' => $this->build(), 'seriesList' => $seriesList, diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index 02de018..2b54c25 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -18,9 +18,6 @@ // Toned glance-dots on each collapsed release row (static classes so Tailwind's scanner sees them). $dotClass = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'neutral' => 'bg-ink-4']; - $repoHost = parse_url($repository, PHP_URL_HOST); - $repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/'); - $isUpdate = $updateState === 'update'; @endphp @@ -288,14 +285,16 @@ diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index f1f023c..9e65198 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -306,35 +306,62 @@ class VersionUpdateCheckTest extends TestCase ); } - // ── Project link: only the dev box may expose its (possibly private) source ─── + // ── Project links: dev lists source + public; everyone else only public ────── - public function test_dev_box_shows_its_actual_repository_on_the_project_card(): void + /** @param array $repos */ + private static function urls(array $repos): array { - config()->set('clusev.release_controls', true); // the dev / release-control box - Http::fake(); - - // setUp() set clusev.repository to the (Gitea-style) source — the dev box may surface it. - Livewire::test(Index::class)->assertViewHas('repository', self::REPO); + return array_column($repos, 'url'); } - public function test_non_dev_install_only_links_the_public_repo(): void + public function test_dev_box_lists_its_source_and_the_public_repo(): void + { + config()->set('clusev.release_controls', true); // the dev / release-control box + config()->set('clusev.public_slug', 'clusev/clusev'); + Http::fake(); + + // setUp() set clusev.repository to the (Gitea-style) source — the dev box lists it FIRST, + // then the public repo. Both visible. + Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ + self::REPO, 'https://github.com/clusev/clusev', + ]); + } + + public function test_dev_box_drops_a_garbage_source_url(): void + { + config()->set('clusev.release_controls', true); + config()->set('clusev.repository', 'not-a-url'); // misconfigured source (no host) + config()->set('clusev.public_slug', 'clusev/clusev'); + Http::fake(); + + // A garbage source is dropped (would render an empty link) — the public repo still shows. + Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ + 'https://github.com/clusev/clusev', + ]); + } + + public function test_non_dev_install_lists_only_the_public_repo(): void { config()->set('clusev.release_controls', false); // staging / stable config()->set('clusev.public_slug', 'clusev/clusev'); Http::fake(); - // Even though clusev.repository points at the private Gitea source (setUp), the project card - // must link ONLY the public repo — a non-dev server never exposes the private source. - Livewire::test(Index::class)->assertViewHas('repository', 'https://github.com/clusev/clusev'); + // Even though clusev.repository points at the private Gitea source (setUp), the card lists + // ONLY the public repo — a non-dev server never exposes the private source. + Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ + 'https://github.com/clusev/clusev', + ]); } - public function test_non_dev_project_link_follows_the_public_slug(): void + public function test_non_dev_public_link_follows_the_public_slug(): void { config()->set('clusev.release_controls', false); config()->set('clusev.public_slug', 'acme/pub'); Http::fake(); - Livewire::test(Index::class)->assertViewHas('repository', 'https://github.com/acme/pub'); + Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ + 'https://github.com/acme/pub', + ]); } /** @@ -343,13 +370,13 @@ class VersionUpdateCheckTest extends TestCase * (possibly private) URL. */ #[DataProvider('malformedSlugs')] - public function test_non_dev_project_link_falls_back_for_a_malformed_slug(string $slug, string $expected): void + public function test_non_dev_public_link_falls_back_for_a_malformed_slug(string $slug, string $expected): void { config()->set('clusev.release_controls', false); config()->set('clusev.public_slug', $slug); Http::fake(); - Livewire::test(Index::class)->assertViewHas('repository', $expected); + Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [$expected]); } /** @return array */