From 8e77f31f002568c799e1ec7cad7ad3ce5c0aaf01 Mon Sep 17 00:00:00 2001 From: boban Date: Wed, 24 Jun 2026 23:41:18 +0200 Subject: [PATCH] fix(versions): non-dev installs only link the public repo on the Projekt card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Versions "Projekt" card linked config('clusev.repository') — the per-install update source, which on the dev box is the private Gitea. A staging/stable install must never surface a private source. Now only the dev/release-control box shows its actual source; every other install links the public open-core repo only, built from the public slug with a canonical fallback (a malformed or full-URL slug can never produce a private/garbage link). The update SOURCE (ReleaseChecker) is unchanged — this governs only the displayed link. Codex review: hardened the slug to reject non-owner/repo values; the release_controls gate is the dev-box invariant (it also gates the dev-only /release page, and a staging box's CLUSEV_REPOSITORY is the public repo anyway). Verified: 432 tests green (incl. dev / non-dev / malformed-slug cases), pint clean, /versions loads 200 with zero console errors. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Versions/Index.php | 28 +++++++++++- tests/Feature/VersionUpdateCheckTest.php | 57 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 57d5913..621a56f 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -563,6 +563,32 @@ 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.) + * + * 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. + */ + private function projectUrl(): string + { + 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'; + } + + return 'https://github.com/'.$slug; + } + public function render() { $releases = $this->releases(); @@ -602,7 +628,7 @@ class Index extends Component return view('livewire.versions.index', [ 'version' => config('clusev.version'), 'channel' => $this->channel(), - 'repository' => config('clusev.repository'), + 'repository' => $this->projectUrl(), 'license' => config('clusev.license'), 'build' => $this->build(), 'seriesList' => $seriesList, diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index c7645b2..f1f023c 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; use Livewire\Livewire; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; /** @@ -304,4 +305,60 @@ class VersionUpdateCheckTest extends TestCase 'the per-user throttle must block the sentinel write', ); } + + // ── Project link: only the dev box may expose its (possibly private) source ─── + + public function test_dev_box_shows_its_actual_repository_on_the_project_card(): void + { + 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); + } + + public function test_non_dev_install_only_links_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'); + } + + public function test_non_dev_project_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'); + } + + /** + * A malformed public_slug must never produce a private/garbage link — it falls back to the + * canonical public repo. Covers empty, surrounding slashes, and a slug mistakenly set to a full + * (possibly private) URL. + */ + #[DataProvider('malformedSlugs')] + public function test_non_dev_project_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); + } + + /** @return array */ + public static function malformedSlugs(): array + { + return [ + 'empty' => ['', 'https://github.com/clusev/clusev'], + 'surrounding slashes' => ['/acme/pub/', 'https://github.com/acme/pub'], + 'full private url' => ['https://gitea.internal/o/repo', 'https://github.com/clusev/clusev'], + ]; + } }