diff --git a/app/Livewire/Source/Github/PermissionsEvents.php b/app/Livewire/Source/Github/PermissionsEvents.php new file mode 100644 index 000000000..968653043 --- /dev/null +++ b/app/Livewire/Source/Github/PermissionsEvents.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); + + if (! data_get($this->github_app, 'app_id')) { + $this->redirectRoute('source.github.show', ['github_app_uuid' => $this->github_app->uuid], navigate: true); + } + } + + public function render() + { + return view('livewire.source.github.permissions-events'); + } +} diff --git a/app/Livewire/Source/Github/Resources.php b/app/Livewire/Source/Github/Resources.php new file mode 100644 index 000000000..27300b622 --- /dev/null +++ b/app/Livewire/Source/Github/Resources.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); + + if (! data_get($this->github_app, 'app_id')) { + $this->redirectRoute('source.github.show', ['github_app_uuid' => $this->github_app->uuid], navigate: true); + } + } + + public function render() + { + return view('livewire.source.github.resources'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/General.php b/app/Livewire/Source/Github/Tabs/General.php new file mode 100644 index 000000000..8c08e5444 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/General.php @@ -0,0 +1,248 @@ + 'required|string', + 'organization' => 'nullable|string', + 'apiUrl' => 'required|string', + 'htmlUrl' => 'required|string', + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'appId' => 'nullable|int', + 'installationId' => 'nullable|int', + 'clientId' => 'nullable|string', + 'clientSecret' => 'nullable|string', + 'webhookSecret' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'privateKeyId' => 'nullable|int', + ]; + + public function mount(string $githubAppUuid): void + { + $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->applications = $this->github_app->applications; + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + + $this->syncData(); + $this->name = str($this->github_app->name)->kebab(); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->github_app->name = $this->name; + $this->github_app->organization = $this->organization; + $this->github_app->api_url = $this->apiUrl; + $this->github_app->html_url = $this->htmlUrl; + $this->github_app->custom_user = $this->customUser; + $this->github_app->custom_port = $this->customPort; + $this->github_app->app_id = $this->appId; + $this->github_app->installation_id = $this->installationId; + $this->github_app->client_id = $this->clientId; + $this->github_app->client_secret = $this->clientSecret; + $this->github_app->webhook_secret = $this->webhookSecret; + $this->github_app->is_system_wide = $this->isSystemWide; + $this->github_app->private_key_id = $this->privateKeyId; + + return; + } + + $this->name = $this->github_app->name; + $this->organization = $this->github_app->organization; + $this->apiUrl = $this->github_app->api_url; + $this->htmlUrl = $this->github_app->html_url; + $this->customUser = $this->github_app->custom_user; + $this->customPort = $this->github_app->custom_port; + $this->appId = $this->github_app->app_id; + $this->installationId = $this->github_app->installation_id; + $this->clientId = $this->github_app->client_id; + $this->clientSecret = $this->github_app->client_secret; + $this->webhookSecret = $this->github_app->webhook_secret; + $this->isSystemWide = $this->github_app->is_system_wide; + $this->privateKeyId = $this->github_app->private_key_id; + } + + public function getGithubAppNameUpdatePath(): string + { + if (str($this->github_app->organization)->isNotEmpty()) { + return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; + } + + return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; + } + + private function generateGithubJwt(string $privateKey, int $appId): string + { + $configuration = Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($privateKey), + InMemory::plainText($privateKey) + ); + + $now = time(); + + return $configuration->builder() + ->issuedBy((string) $appId) + ->permittedFor('https://api.github.com') + ->identifiedBy((string) $now) + ->issuedAt(new \DateTimeImmutable("@{$now}")) + ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); + } + + public function updateGithubAppName(): void + { + try { + $this->authorize('update', $this->github_app); + + $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); + + if (! $privateKey) { + $this->dispatch('error', 'No private key found for this GitHub App.'); + + return; + } + + if (! $this->github_app->app_id) { + $this->dispatch('error', 'No App ID found for this GitHub App.'); + + return; + } + + $jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'Authorization' => "Bearer {$jwt}", + ])->get("{$this->github_app->api_url}/app"); + + if (! $response->successful()) { + $errorMessage = $response->json()['message'] ?? 'Unknown error'; + $this->dispatch('error', "Failed to fetch GitHub App information: {$errorMessage}"); + + return; + } + + $appData = $response->json(); + $appSlug = $appData['slug'] ?? null; + + if (! $appSlug) { + $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); + + return; + } + + $this->github_app->name = $appSlug; + $this->name = str($appSlug)->kebab(); + $privateKey->name = "github-app-{$appSlug}"; + $privateKey->save(); + $this->github_app->save(); + $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function submit(): void + { + try { + $this->authorize('update', $this->github_app); + + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->validate(); + $this->syncData(true); + $this->github_app->save(); + $this->dispatch('success', 'Github App updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function instantSave(): void + { + try { + $this->authorize('update', $this->github_app); + + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->syncData(true); + $this->github_app->save(); + $this->dispatch('success', 'Github App updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete() + { + try { + $this->authorize('delete', $this->github_app); + + if ($this->github_app->applications->isNotEmpty()) { + $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + + return; + } + + $this->github_app->delete(); + + return redirect()->route('source.all'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.source.github.tabs.general'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/PermissionsEvents.php b/app/Livewire/Source/Github/Tabs/PermissionsEvents.php new file mode 100644 index 000000000..0b57adc27 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/PermissionsEvents.php @@ -0,0 +1,105 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->syncData(); + } + + private function syncData(): void + { + $this->appId = $this->github_app->app_id; + $this->privateKeyId = $this->github_app->private_key_id; + $this->contents = $this->github_app->contents; + $this->metadata = $this->github_app->metadata; + $this->pullRequests = $this->github_app->pull_requests; + $this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners; + $this->webhookEvents = $this->github_app->webhook_events; + } + + public function checkPermissions(): void + { + try { + $this->authorize('view', $this->github_app); + + $missingFields = []; + + if (! $this->github_app->app_id) { + $missingFields[] = 'App ID'; + } + + if (! $this->github_app->private_key_id) { + $missingFields[] = 'Private Key'; + } + + if (! empty($missingFields)) { + $fieldsList = implode(', ', $missingFields); + $this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}"); + + return; + } + + if (! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + + $previousEvents = $this->github_app->webhook_events ?? []; + GithubAppPermissionJob::dispatchSync($this->github_app); + $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->syncData(); + + $addedEvents = array_diff($this->github_app->webhook_events ?? [], $previousEvents); + if (! empty($addedEvents)) { + $this->dispatch('success', 'Permissions updated. Auto-enabled missing events: '.implode(', ', $addedEvents)); + + return; + } + + $this->dispatch('success', 'Github App permissions updated.'); + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'DECODER routines::unsupported') || str_contains($errorMessage, 'parse your key')) { + $this->dispatch('error', 'The selected private key format is not supported for GitHub Apps.

Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY).

OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.'); + + return; + } + + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.source.github.tabs.permissions-events'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/Resources.php b/app/Livewire/Source/Github/Tabs/Resources.php new file mode 100644 index 000000000..cb79aaf44 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/Resources.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->applications = $this->github_app->applications; + } + + public function render() + { + return view('livewire.source.github.tabs.resources'); + } +} diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 81f264e31..033066c85 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -1,231 +1,30 @@
@if (data_get($github_app, 'app_id')) -
-
-

GitHub App

-
- @if (data_get($github_app, 'installation_id')) - Save - @endif - @can('delete', $github_app) - @if ($applications->count() > 0) - - @else - - @endif - @endcan -
-
-
Your Private GitHub App for private repositories.
- @if (!data_get($github_app, 'installation_id')) -
- - - - You must complete this step before you can use this source! -
- - Install Repositories on GitHub +
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+
+ @else

GitHub App

diff --git a/resources/views/livewire/source/github/permissions-events.blade.php b/resources/views/livewire/source/github/permissions-events.blade.php new file mode 100644 index 000000000..ea444de50 --- /dev/null +++ b/resources/views/livewire/source/github/permissions-events.blade.php @@ -0,0 +1,28 @@ +
+
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+ + + +
diff --git a/resources/views/livewire/source/github/resources.blade.php b/resources/views/livewire/source/github/resources.blade.php new file mode 100644 index 000000000..d898c9167 --- /dev/null +++ b/resources/views/livewire/source/github/resources.blade.php @@ -0,0 +1,28 @@ +
+
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+ + + +
diff --git a/resources/views/livewire/source/github/tabs/general.blade.php b/resources/views/livewire/source/github/tabs/general.blade.php new file mode 100644 index 000000000..adf9d2ffc --- /dev/null +++ b/resources/views/livewire/source/github/tabs/general.blade.php @@ -0,0 +1,118 @@ +
+
+
+

General

+
+ @if (data_get($github_app, 'installation_id')) + Save + @endif + @can('delete', $github_app) + @if ($applications->count() > 0) + + @else + + @endif + @endcan +
+
+ + @if (!data_get($github_app, 'installation_id')) +
+ + + + You must complete this step before you can use this source! +
+ + Install Repositories on GitHub + + @else +
+
+
+ + + Sync Name + + @can('update', $github_app) + + + Rename + + + + + + Update Repositories + + + + @endcan +
+
+ + @if (!isCloud()) +
+ +
+ @if ($isSystemWide) + + System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead. + + @endif + @endif +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + @if (blank($github_app->private_key_id)) + + @endif + @foreach ($privateKeys as $privateKey) + + @endforeach + +
+
+ @endif +
+
diff --git a/resources/views/livewire/source/github/tabs/permissions-events.blade.php b/resources/views/livewire/source/github/tabs/permissions-events.blade.php new file mode 100644 index 000000000..3f13c0696 --- /dev/null +++ b/resources/views/livewire/source/github/tabs/permissions-events.blade.php @@ -0,0 +1,55 @@ +
+
+
+

Permissions & Events

+ @if (data_get($github_app, 'installation_id')) + @can('view', $github_app) + Refetch + + + Update + + + + @endcan + @endif +
+ + @if (!data_get($github_app, 'installation_id')) +
+ Install the GitHub App first to manage permissions and webhook events. +
+ @else +
+ + + + +
+

Webhook Events

+ @if ($webhookEvents) +
+ @foreach ($webhookEvents as $event) + {{ $event }} + @endforeach +
+ @php + $missingEvents = $github_app->missingWebhookEvents(); + @endphp + @if (!empty($missingEvents)) +
+ Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }} +
+ @endif + @else +
+ No webhook event data yet. Click Refetch above to fetch current events. +
+ @endif + @endif +
+
diff --git a/resources/views/livewire/source/github/tabs/resources.blade.php b/resources/views/livewire/source/github/tabs/resources.blade.php new file mode 100644 index 000000000..453a66735 --- /dev/null +++ b/resources/views/livewire/source/github/tabs/resources.blade.php @@ -0,0 +1,62 @@ +
+
+
+
+

Resources

+
+
Here you can find all resources that are using this source.
+
+ + @if (!data_get($github_app, 'installation_id')) +
+ Install the GitHub App first to link resources. +
+ @elseif ($applications->isEmpty()) +
+ No resources are currently using this GitHub App. +
+ @else +
+
+
+
+
+ + + + + + + + + + + @foreach ($applications->sortBy('name', SORT_NATURAL) as $resource) + + + + + + + @endforeach + +
ProjectEnvironmentNameType
+ {{ data_get($resource->project(), 'name') }} + + {{ data_get($resource, 'environment.name') }} + + + {{ $resource->name }} + + + + {{ str($resource->type())->headline() }} +
+
+
+
+
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 89425f8da..ef3879371 100644 --- a/routes/web.php +++ b/routes/web.php @@ -75,6 +75,8 @@ use App\Livewire\SharedVariables\Project\Index as ProjectSharedVariablesIndex; use App\Livewire\SharedVariables\Project\Show as ProjectSharedVariablesShow; use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex; use App\Livewire\Source\Github\Change as GitHubChange; +use App\Livewire\Source\Github\PermissionsEvents as GitHubPermissionsEvents; +use App\Livewire\Source\Github\Resources as GitHubResources; use App\Livewire\Storage\Index as StorageIndex; use App\Livewire\Storage\Show as StorageShow; use App\Livewire\Subscription\Index as SubscriptionIndex; @@ -302,6 +304,8 @@ Route::middleware(['auth'])->group(function () { ]); })->name('source.all'); Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show'); + Route::get('/source/github/{github_app_uuid}/permissions-events', GitHubPermissionsEvents::class)->name('source.github.permissions-events'); + Route::get('/source/github/{github_app_uuid}/resources', GitHubResources::class)->name('source.github.resources'); }); Route::middleware(['auth'])->group(function () { diff --git a/tests/Feature/GithubSourcePagesTest.php b/tests/Feature/GithubSourcePagesTest.php new file mode 100644 index 000000000..f471f2b80 --- /dev/null +++ b/tests/Feature/GithubSourcePagesTest.php @@ -0,0 +1,56 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::create(['id' => 0]); +}); + +test('github source has dedicated routes for each tab page', function () { + $githubApp = GithubApp::create([ + 'name' => 'test-github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $this->get(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); + $this->get(route('source.github.permissions-events', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); + $this->get(route('source.github.resources', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); +}); + +test('permissions and resources routes redirect to general if github app is not initialized yet', function () { + $githubApp = GithubApp::create([ + 'name' => 'test-github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $this->get(route('source.github.permissions-events', ['github_app_uuid' => $githubApp->uuid])) + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); + $this->get(route('source.github.resources', ['github_app_uuid' => $githubApp->uuid])) + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); +});