mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 04:23:34 +00:00
fix(github): sync app slug before building install URL
Move GitHub App JWT generation and slug synchronization into shared helpers so installation URLs use the canonical GitHub slug. Encode GHE organization path segments and keep the app-scoped fallback for blank organizations.
This commit is contained in:
@@ -8,11 +8,7 @@ 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 Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||
use Livewire\Component;
|
||||
|
||||
class Change extends Component
|
||||
@@ -303,64 +299,24 @@ class Change extends Component
|
||||
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
|
||||
}
|
||||
|
||||
private function generateGithubJwt($private_key, $app_id): string
|
||||
{
|
||||
$configuration = Configuration::forAsymmetricSigner(
|
||||
new Sha256,
|
||||
InMemory::plainText($private_key),
|
||||
InMemory::plainText($private_key)
|
||||
);
|
||||
|
||||
$now = time();
|
||||
|
||||
return $configuration->builder()
|
||||
->issuedBy((string) $app_id)
|
||||
->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()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id);
|
||||
|
||||
if (! $privateKey) {
|
||||
if (! PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id)) {
|
||||
$this->dispatch('error', 'No private key found for this GitHub App.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id);
|
||||
$appSlug = syncGithubAppName($this->github_app, true);
|
||||
|
||||
$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()) {
|
||||
$app_data = $response->json();
|
||||
$app_slug = $app_data['slug'] ?? null;
|
||||
|
||||
if ($app_slug) {
|
||||
$this->github_app->name = $app_slug;
|
||||
$this->name = str($app_slug)->kebab();
|
||||
$privateKey->name = "github-app-{$app_slug}";
|
||||
$privateKey->save();
|
||||
$this->github_app->save();
|
||||
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
|
||||
} else {
|
||||
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
||||
}
|
||||
if ($appSlug) {
|
||||
$this->name = str($appSlug)->kebab();
|
||||
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
|
||||
} else {
|
||||
$error_message = $response->json()['message'] ?? 'Unknown error';
|
||||
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
|
||||
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -117,8 +118,75 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
];
|
||||
}
|
||||
|
||||
function generateGithubAppJwt(string $privateKey, string|int $appId): string
|
||||
{
|
||||
$algorithm = new Sha256;
|
||||
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
||||
$now = CarbonImmutable::now()->setTimezone('UTC');
|
||||
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
|
||||
|
||||
return $tokenBuilder
|
||||
->issuedBy((string) $appId)
|
||||
->issuedAt($now->modify('-1 minute'))
|
||||
->expiresAt($now->modify('+8 minutes'))
|
||||
->getToken($algorithm, InMemory::plainText($privateKey))
|
||||
->toString();
|
||||
}
|
||||
|
||||
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
|
||||
{
|
||||
try {
|
||||
if (blank($source->app_id) || blank($source->private_key_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
|
||||
|
||||
if (! $privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
'Authorization' => "Bearer {$jwt}",
|
||||
])->get("{$source->api_url}/app");
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
|
||||
}
|
||||
|
||||
$appSlug = data_get($response->json(), 'slug');
|
||||
|
||||
if (blank($appSlug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source->name = $appSlug;
|
||||
|
||||
if ($source->exists) {
|
||||
$source->save();
|
||||
}
|
||||
|
||||
$privateKey->name = "github-app-{$appSlug}";
|
||||
$privateKey->save();
|
||||
|
||||
return $appSlug;
|
||||
} catch (Throwable $e) {
|
||||
if ($throw) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source): string
|
||||
{
|
||||
syncGithubAppName($source);
|
||||
|
||||
$name = str(Str::kebab($source->name));
|
||||
$baseUrl = rtrim($source->html_url, '/');
|
||||
$host = parse_url($source->html_url, PHP_URL_HOST);
|
||||
@@ -137,6 +205,8 @@ function getInstallationPath(GithubApp $source): string
|
||||
$organization = str($source->organization)->trim('/');
|
||||
|
||||
if ($organization->isNotEmpty()) {
|
||||
$organization = rawurlencode((string) $organization);
|
||||
|
||||
return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,68 @@ describe('GitHub Source Change Component', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('installation path synchronizes github app slug before generating the url', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'actual-github-slug']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'github-app-local-name',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Local Display Name',
|
||||
'organization' => 'acme-enterprise',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/actual-github-slug/installations/new?')
|
||||
->and($githubApp->refresh()->name)->toBe('actual-github-slug')
|
||||
->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug');
|
||||
});
|
||||
|
||||
test('ghe.com installation path encodes the organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => '/acme enterprise/',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'provided-github-app',
|
||||
'organization' => null,
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
@@ -231,6 +293,10 @@ describe('GitHub Source Change Component', function () {
|
||||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
@@ -265,6 +331,10 @@ describe('GitHub Source Change Component', function () {
|
||||
});
|
||||
|
||||
test('can update github app from null to valid values', function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||
]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
|
||||
Reference in New Issue
Block a user