mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Scope resource permissions and related records to the owning team across team switches, cloning, deployments, and resource operations. Merge ClickHouse volume mounts, preserve anonymous data volumes on stop, and require adopting current data before starting or restarting. Fix file storage deletion checks and add regression coverage.
503 lines
18 KiB
PHP
503 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Source\Github;
|
|
|
|
use App\Jobs\GithubAppPermissionJob;
|
|
use App\Models\GithubApp;
|
|
use App\Models\PrivateKey;
|
|
use App\Rules\SafeExternalUrl;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Component;
|
|
|
|
class Change extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
public string $webhook_endpoint = '';
|
|
|
|
public string $custom_webhook_endpoint = '';
|
|
|
|
public bool $use_custom_webhook_endpoint = false;
|
|
|
|
public ?string $ipv4 = null;
|
|
|
|
public ?string $ipv6 = null;
|
|
|
|
public ?string $fqdn = null;
|
|
|
|
public ?bool $default_permissions = true;
|
|
|
|
public ?bool $preview_deployment_permissions = true;
|
|
|
|
public ?bool $administration = false;
|
|
|
|
public ?bool $github_runners = false;
|
|
|
|
public $parameters;
|
|
|
|
public ?GithubApp $github_app = null;
|
|
|
|
// Explicit properties
|
|
public string $name;
|
|
|
|
public ?string $organization = null;
|
|
|
|
public string $apiUrl;
|
|
|
|
public string $htmlUrl;
|
|
|
|
public string $customUser;
|
|
|
|
public int $customPort;
|
|
|
|
public ?int $appId = null;
|
|
|
|
public ?int $installationId = null;
|
|
|
|
public ?string $clientId = null;
|
|
|
|
public ?string $clientSecret = null;
|
|
|
|
public ?string $webhookSecret = null;
|
|
|
|
public bool $isSystemWide;
|
|
|
|
public ?int $privateKeyId = null;
|
|
|
|
public ?string $contents = null;
|
|
|
|
public ?string $metadata = null;
|
|
|
|
public ?string $pullRequests = null;
|
|
|
|
public $applications;
|
|
|
|
public $privateKeys;
|
|
|
|
public string $manifestState = '';
|
|
|
|
public string $activeTab = 'general';
|
|
|
|
public bool $isConnected = false;
|
|
|
|
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
|
|
|
|
protected function rules(): array
|
|
{
|
|
return [
|
|
'name' => 'required|string',
|
|
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
|
|
'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
|
|
'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
|
|
'customUser' => 'required|string',
|
|
'customPort' => 'required|int',
|
|
'appId' => 'nullable|int',
|
|
'installationId' => 'nullable|int',
|
|
'clientId' => 'nullable|string',
|
|
'clientSecret' => 'nullable|string',
|
|
'webhookSecret' => 'nullable|string',
|
|
'isSystemWide' => 'required|bool',
|
|
'contents' => 'nullable|string',
|
|
'metadata' => 'nullable|string',
|
|
'pullRequests' => 'nullable|string',
|
|
'privateKeyId' => ['nullable', 'integer', Rule::exists('private_keys', 'id')->where('team_id', $this->github_app->team_id)],
|
|
'webhook_endpoint' => ['required', 'string', 'url'],
|
|
'custom_webhook_endpoint' => ['nullable', 'string', 'url'],
|
|
'use_custom_webhook_endpoint' => ['required', 'bool'],
|
|
];
|
|
}
|
|
|
|
public function updatingHtmlUrl(): void
|
|
{
|
|
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|
|
|| $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
|
|
}
|
|
|
|
public function updatedHtmlUrl(): void
|
|
{
|
|
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
|
|
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync data between component properties and model
|
|
*
|
|
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
|
|
*/
|
|
private function syncData(bool $toModel = false): void
|
|
{
|
|
if ($toModel) {
|
|
// Sync TO model (before save)
|
|
$this->organization = normalizeGithubOrganization($this->organization);
|
|
$this->apiUrl = filled($this->apiUrl)
|
|
? $this->apiUrl
|
|
: githubApiUrlFromHtmlUrl($this->htmlUrl);
|
|
|
|
$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;
|
|
$this->github_app->contents = $this->contents;
|
|
$this->github_app->metadata = $this->metadata;
|
|
$this->github_app->pull_requests = $this->pullRequests;
|
|
} else {
|
|
// Sync FROM model (on load/refresh)
|
|
$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;
|
|
$canUpdate = auth()->user()->can('update', $this->github_app);
|
|
$this->clientSecret = $canUpdate ? $this->github_app->client_secret : null;
|
|
$this->webhookSecret = $canUpdate ? $this->github_app->webhook_secret : null;
|
|
$this->isSystemWide = $this->github_app->is_system_wide;
|
|
$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;
|
|
}
|
|
}
|
|
|
|
private function githubAppSetupStateCacheKey(string $state): string
|
|
{
|
|
return 'github-app-setup-state:'.hash('sha256', $state);
|
|
}
|
|
|
|
private function createGithubAppSetupState(string $action): string
|
|
{
|
|
$state = Str::random(64);
|
|
|
|
Cache::put($this->githubAppSetupStateCacheKey($state), [
|
|
'action' => $action,
|
|
'github_app_id' => $this->github_app->id,
|
|
'team_id' => $this->github_app->team_id,
|
|
], now()->addMinutes(60));
|
|
|
|
return $state;
|
|
}
|
|
|
|
public function checkPermissions()
|
|
{
|
|
try {
|
|
$this->authorize('view', $this->github_app);
|
|
|
|
// Validate required fields before attempting to fetch permissions
|
|
$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;
|
|
}
|
|
|
|
// Verify the private key exists and is accessible
|
|
if (! $this->github_app->privateKey) {
|
|
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
|
|
|
|
return;
|
|
}
|
|
|
|
syncGithubAppName($this->github_app);
|
|
|
|
GithubAppPermissionJob::dispatchSync($this->github_app);
|
|
$this->github_app->refresh();
|
|
$this->syncData(false);
|
|
$this->isConnected = $this->github_app->isConnected();
|
|
$this->name = str($this->github_app->name)->kebab();
|
|
|
|
$this->dispatch('success', 'Github App permissions updated.');
|
|
} catch (\Throwable $e) {
|
|
// Provide better error message for unsupported key formats
|
|
$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. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
|
|
|
|
return;
|
|
}
|
|
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function testConnection()
|
|
{
|
|
try {
|
|
$this->authorize('view', $this->github_app);
|
|
|
|
if (! $this->github_app->isConnected()) {
|
|
$this->dispatch('error', 'GitHub App is not fully set up. Please complete installation first.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->github_app->private_key_id || ! $this->github_app->privateKey) {
|
|
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
|
|
|
|
return;
|
|
}
|
|
|
|
$jwt = generateGithubJwt($this->github_app);
|
|
$appResponse = Http::GitSource($this->github_app->api_url)->withHeaders([
|
|
'Authorization' => "Bearer $jwt",
|
|
'Accept' => 'application/vnd.github+json',
|
|
])->timeout(10)->get("{$this->github_app->api_url}/app");
|
|
|
|
if (! $appResponse->successful()) {
|
|
$error = data_get($appResponse->json(), 'message', 'Unknown error');
|
|
$this->dispatch('error', "Connection failed: {$error}");
|
|
|
|
return;
|
|
}
|
|
|
|
// Confirm installation credentials can mint an installation access token.
|
|
generateGithubInstallationToken($this->github_app);
|
|
|
|
$appName = data_get($appResponse->json(), 'name')
|
|
?? data_get($appResponse->json(), 'slug', 'unknown');
|
|
$this->dispatch('success', "Connection successful! Authenticated as GitHub App: {$appName}");
|
|
} 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. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
|
|
|
|
return;
|
|
}
|
|
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function mount()
|
|
{
|
|
try {
|
|
$github_app_uuid = request()->github_app_uuid;
|
|
$this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail();
|
|
$this->authorize('view', $this->github_app);
|
|
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
|
|
|
$this->applications = $this->github_app->applications;
|
|
$settings = instanceSettings();
|
|
|
|
// Sync data from model to properties
|
|
$this->syncData(false);
|
|
if (! $this->github_app->app_id) {
|
|
$this->preview_deployment_permissions = request()->boolean('previews', true);
|
|
$this->github_runners = filled($this->github_app->organization) && request()->boolean('runners');
|
|
}
|
|
$this->isConnected = $this->github_app->isConnected();
|
|
|
|
// Override name with kebab case for display
|
|
$this->name = str($this->github_app->name)->kebab();
|
|
$this->fqdn = $settings->fqdn;
|
|
$this->manifestState = $this->createGithubAppSetupState('manifest');
|
|
|
|
if ($settings->public_ipv4) {
|
|
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
|
|
}
|
|
if ($settings->public_ipv6) {
|
|
$this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port');
|
|
}
|
|
if ($this->github_app->installation_id && session('from')) {
|
|
$source_id = data_get(session('from'), 'source_id');
|
|
if (! $source_id || $this->github_app->id !== $source_id) {
|
|
session()->forget('from');
|
|
} else {
|
|
$parameters = data_get(session('from'), 'parameters');
|
|
$back = data_get(session('from'), 'back');
|
|
$environment_uuid = data_get($parameters, 'environment_uuid');
|
|
$project_uuid = data_get($parameters, 'project_uuid');
|
|
$type = data_get($parameters, 'type');
|
|
$destination = data_get($parameters, 'destination');
|
|
session()->forget('from');
|
|
|
|
return redirect()->route($back, [
|
|
'environment_uuid' => $environment_uuid,
|
|
'project_uuid' => $project_uuid,
|
|
'type' => $type,
|
|
'destination' => $destination,
|
|
]);
|
|
}
|
|
}
|
|
$this->parameters = get_route_parameters();
|
|
$routeName = request()->route()?->getName();
|
|
if ($routeName === 'source.github.permissions') {
|
|
$this->activeTab = 'permissions';
|
|
} elseif ($routeName === 'source.github.resources') {
|
|
$this->activeTab = 'resources';
|
|
} elseif ($routeName === 'source.github.danger') {
|
|
$this->activeTab = 'danger';
|
|
} else {
|
|
$this->activeTab = 'general';
|
|
}
|
|
if (isCloud() && ! isDev()) {
|
|
$this->webhook_endpoint = config('app.url');
|
|
} else {
|
|
$this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? '';
|
|
$this->is_system_wide = $this->github_app->is_system_wide;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function getGithubAppNameUpdatePath()
|
|
{
|
|
$name = encodeGithubPathSegment($this->github_app->name);
|
|
$organization = normalizeGithubOrganization($this->github_app->organization);
|
|
|
|
if (filled($organization)) {
|
|
return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
|
|
}
|
|
|
|
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
|
|
}
|
|
|
|
public function updateGithubAppName()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->github_app);
|
|
|
|
$this->github_app->app_id = $this->appId;
|
|
$this->github_app->private_key_id = $this->privateKeyId;
|
|
$this->github_app->unsetRelation('privateKey');
|
|
|
|
if (! $this->appId) {
|
|
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! PrivateKey::where('team_id', $this->github_app->team_id)->find($this->privateKeyId)) {
|
|
$this->dispatch('error', 'No private key found for this GitHub App.');
|
|
|
|
return;
|
|
}
|
|
|
|
$appSlug = syncGithubAppName($this->github_app, true);
|
|
|
|
if ($appSlug) {
|
|
$this->name = str($appSlug)->kebab();
|
|
$this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
|
|
} else {
|
|
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function submit()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->github_app);
|
|
|
|
$this->organization = normalizeGithubOrganization($this->organization);
|
|
$this->apiUrl = filled($this->apiUrl)
|
|
? $this->apiUrl
|
|
: githubApiUrlFromHtmlUrl($this->htmlUrl);
|
|
$this->validate();
|
|
|
|
$this->syncData(true);
|
|
$this->github_app->save();
|
|
$this->isConnected = $this->github_app->isConnected();
|
|
$this->dispatch('success', 'Github App updated.');
|
|
} catch (ValidationException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function createGithubAppManually()
|
|
{
|
|
$this->authorize('update', $this->github_app);
|
|
|
|
$this->github_app->app_id = 1234567890;
|
|
$this->github_app->installation_id = 1234567890;
|
|
$this->github_app->save();
|
|
|
|
// Redirect to avoid Livewire morphing issues when view structure changes
|
|
return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid])
|
|
->with('success', 'Github App updated. You can now configure the details.');
|
|
}
|
|
|
|
public function instantSave()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->github_app);
|
|
|
|
$this->validateOnly('privateKeyId');
|
|
|
|
$this->syncData(true);
|
|
$this->github_app->save();
|
|
$this->isConnected = $this->github_app->isConnected();
|
|
$this->dispatch('success', 'Github App updated.');
|
|
} catch (\Throwable $e) {
|
|
return 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.');
|
|
|
|
return;
|
|
}
|
|
$this->github_app->delete();
|
|
// Clear so post-delete Livewire re-render / modal $refresh does not re-run
|
|
// @can and canGate checks against a deleted model (null team_id TypeError).
|
|
$this->github_app = null;
|
|
|
|
return redirectRoute($this, 'source.all');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After a delete, the confirmation modal refreshes the component before the redirect runs.
|
|
*/
|
|
public function render()
|
|
{
|
|
if (! $this->github_app) {
|
|
return '<div></div>';
|
|
}
|
|
|
|
return view('livewire.source.github.change');
|
|
}
|
|
}
|