Files
coolify/app/Http/Controllers/Webhook/Github.php
T
Andras Bacsai 554bd354bf fix: support host storage paths and clean up cancelled deployments
Allow validated host file and directory mounts outside resource directories
while protecting external paths from deletion.

Clean up cancelled deployment containers and clear tracked process IDs.
Preserve legacy KeyDB and Dragonfly password parsing, accept server backup
paths without extension restrictions, and allow Compose variable proxy ports.
2026-10-05 11:08:52 +02:00

835 lines
40 KiB
PHP

<?php
namespace App\Http\Controllers\Webhook;
use App\Enums\GithubRunnerStatus;
use App\Exceptions\InvalidWebhookPayloadException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class Github extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
use ReadsWebhookPushPayload;
public function manual(Request $request)
{
try {
$return_payloads = collect([]);
$x_github_delivery = request()->header('X-GitHub-Delivery');
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
$content_type = $request->header('Content-Type');
$payload = $request->collect();
if ($x_github_event === 'ping') {
// Just pong
return response('pong');
}
if ($content_type !== 'application/json') {
$form_payload = data_get($payload, 'payload');
$payload = is_string($form_payload) ? json_decode($form_payload, true) : null;
}
if ($x_github_event === 'push') {
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
$full_name = data_get($payload, 'repository.full_name');
$commit = $this->webhookCommitSha($payload, 'after');
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
}
if ($x_github_event === 'pull_request') {
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_title' => $pull_request_title,
'branch' => $branch,
'base_branch' => $base_branch,
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'author_association' => $author_association,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = data_get($payload, 'repository.full_name');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
return response('Nothing to do. No branch found in the request.');
}
// A deleted branch has no commit to deploy. No secret is checked here.
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
return response('Nothing to do. Branch deleted.');
}
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$matched_branch = match (true) {
$x_github_event === 'push' => $branch,
$action === 'closed' => null,
default => $base_branch,
};
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch);
// A redelivery of the same signed payload is one guess.
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256);
$applications = Application::query();
if ($x_github_event === 'push' || $action !== 'closed') {
$applications->where('git_branch', $matched_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
$applicationsByServer = $applications->groupBy(function ($app) {
return $app->destination->server_id;
});
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Server is not functional.',
]);
continue;
}
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: $commit ?? 'HEAD',
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Deployment queued.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
]);
}
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
}
if ($x_github_event === 'pull_request') {
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: null,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
} catch (InvalidWebhookPayloadException $e) {
return response($e->getMessage());
} catch (Exception $e) {
return handleError($e);
}
}
/**
* Handles GitHub Actions runner demand. Every lookup is scoped to the App whose signature was verified.
* GitHub can give a job to any idle runner with matching labels, so progress is matched by runner name.
*/
private function handleWorkflowJob(GithubApp $githubApp, Collection $payload)
{
$action = data_get($payload, 'action');
$jobId = (int) data_get($payload, 'workflow_job.id', 0);
$runnerName = (string) data_get($payload, 'workflow_job.runner_name', '');
if ($jobId <= 0) {
return response('Nothing to do. No workflow job found.');
}
// A public App can be installed by other accounts; only its own installation may use the runners.
$installationId = data_get($payload, 'installation.id');
if (blank($githubApp->installation_id) || ! is_numeric($installationId) || (int) $installationId !== (int) $githubApp->installation_id) {
return response('Nothing to do. The job belongs to another installation of the GitHub App.');
}
$jobDetails = array_filter([
'workflow_job_id' => $jobId,
'workflow_job_html_url' => data_get($payload, 'workflow_job.html_url'),
'workflow_name' => data_get($payload, 'workflow_job.workflow_name'),
'job_name' => data_get($payload, 'workflow_job.name'),
'repository_full_name' => data_get($payload, 'repository.full_name'),
], fn ($value) => filled($value));
if ($action === 'queued') {
$labels = array_values(array_filter((array) data_get($payload, 'workflow_job.labels', []), 'is_string'));
$matchingConfigs = $githubApp->runnerConfigs()
->where('is_enabled', true)
->get()
->filter(fn (GithubRunnerConfig $config) => $config->matchesLabels($labels));
if ($matchingConfigs->isEmpty()) {
return response('Nothing to do. No runner configuration matches the job labels.');
}
$isPullRequest = $matchingConfigs->contains(fn (GithubRunnerConfig $config) => ! $config->allow_pull_requests)
&& $this->isPullRequestWorkflowJob($githubApp, $payload);
if ($isPullRequest && ! $matchingConfigs->contains('allow_pull_requests', true)) {
return response('Nothing to do. The runner configuration does not allow pull request jobs.');
}
$execution = GithubRunnerExecution::createOrFirst(
['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId],
[...$jobDetails, 'labels' => $labels, 'is_pull_request' => $isPullRequest, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()],
);
if ($execution->wasRecentlyCreated) {
ProvisionGithubRunnerJob::dispatch($execution->id);
}
return response('Runner queued.');
}
$execution = filled($runnerName)
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
: null;
if ($action === 'in_progress' && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
$execution->update([...$jobDetails, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]);
return response('Runner marked running.');
}
if ($action === 'completed') {
if ($execution) {
$execution->update([...$jobDetails, 'conclusion' => data_get($payload, 'workflow_job.conclusion')]);
if ($execution->isActive() || $execution->status === GithubRunnerStatus::Failed) {
$execution->finish(GithubRunnerStatus::Completed);
CleanupGithubRunnerJob::dispatch($execution->id);
}
return response('Runner cleanup queued.');
}
// The job ended without a Coolify runner, so its queued runner is no longer needed.
GithubRunnerExecution::query()
->where('github_app_id', $githubApp->id)
->where('trigger_workflow_job_id', $jobId)
->where('status', GithubRunnerStatus::Queued)
->first()
?->finish(GithubRunnerStatus::Cancelled, 'The job ended before a runner started.');
return response('Queued runner cancelled.');
}
return response('Nothing to do.');
}
/**
* Whether a queued job belongs to a workflow run started by a pull request, so no runner is started
* for a job that the job-started hook would refuse. The `workflow_job` payload has no event, so the
* run is read from GitHub. When that fails, the job counts as no pull request job and the hook still
* refuses it in the runner.
*/
private function isPullRequestWorkflowJob(GithubApp $githubApp, Collection $payload): bool
{
$runId = (int) data_get($payload, 'workflow_job.run_id', 0);
$repository = (string) data_get($payload, 'repository.full_name', '');
if ($runId <= 0 || ! str_contains($repository, '/')) {
return false;
}
try {
$event = (new GithubRunnerApi($githubApp))->workflowRunEvent($repository, $runId);
} catch (\Throwable) {
return false;
}
return in_array($event, GithubRunnerContainer::PULL_REQUEST_EVENTS, true);
}
public function normal(Request $request)
{
try {
$return_payloads = collect([]);
$id = null;
$x_github_delivery = $request->header('X-GitHub-Delivery');
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
$payload = $request->collect();
if ($x_github_event === 'ping') {
// Just pong
return response('pong');
}
$github_app = GithubApp::where('app_id', $x_github_hook_installation_target_id)->first();
if (is_null($github_app)) {
return response('Nothing to do. No GitHub App found.');
}
$webhook_secret = data_get($github_app, 'webhook_secret');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
}
if ($x_github_event === 'installation' || $x_github_event === 'installation_repositories') {
// Installation handled by setup redirect url. Repositories queried on-demand.
$action = data_get($payload, 'action');
if ($action === 'new_permissions_accepted') {
GithubAppPermissionJob::dispatch($github_app);
}
return response('cool');
}
if ($x_github_event === 'workflow_job') {
return $this->handleWorkflowJob($github_app, $payload);
}
if ($x_github_event === 'push') {
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
$commit = $this->webhookCommitSha($payload, 'after');
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
}
if ($x_github_event === 'pull_request') {
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_title' => $pull_request_title,
'branch' => $branch,
'base_branch' => $base_branch,
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'author_association' => $author_association,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
return response('Nothing to do. No id or branch found.');
}
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
return response('Nothing to do. Branch deleted.');
}
$applications = Application::where('repository_project_id', $id)
->where('source_id', $github_app->id)
->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$branch'.");
}
}
if ($x_github_event === 'pull_request') {
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
}
$applicationsByServer = $applications->groupBy(function ($app) {
return $app->destination->server_id;
});
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Server is not functional.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
commit: $commit ?? 'HEAD',
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => $commit,
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'] ?? null,
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
}
if ($x_github_event === 'pull_request') {
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: $github_app->id,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
return response($return_payloads);
} catch (InvalidWebhookPayloadException $e) {
return response($e->getMessage());
} catch (Exception $e) {
return handleError($e);
}
}
/**
* Read and validate the pull_request payload fields that the handlers use.
*
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, author_association: ?string, is_fork_pull_request: bool}
*
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
*/
private function readPullRequestPayload(mixed $payload): array
{
$head_sha = $this->webhookCommitSha($payload, 'pull_request.head.sha');
return [
'action' => $this->webhookPayloadString($payload, 'action', required: true),
'pull_request_id' => $this->webhookPullRequestId($payload, 'number'),
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url', required: true),
'pull_request_title' => $this->webhookPayloadString($payload, 'pull_request.title'),
'branch' => $this->webhookString(data_get($payload, 'pull_request.head.ref')),
'base_branch' => $this->webhookString(data_get($payload, 'pull_request.base.ref')),
'before_sha' => $this->webhookCommitSha($payload, 'before'),
'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha,
'commit_sha' => $head_sha ?? 'HEAD',
'author_association' => $this->webhookPayloadString($payload, 'pull_request.author_association'),
'is_fork_pull_request' => $this->isForkPullRequest($payload),
];
}
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
*
* @throws InvalidWebhookPayloadException When a repository id is not a positive integer.
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = $this->webhookPayloadId($payload, 'pull_request.head.repo.id');
$baseRepoId = $this->webhookPayloadId($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return $headRepoId !== $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
}
public function redirect(Request $request)
{
$code = (string) $request->query('code', '');
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::GitSource($api_url)->withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
$setup_action = (string) $request->query('setup_action', '');
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
'GitHub App installation could not be verified.'
);
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
return redirect()->route('source.all');
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*/
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
{
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
return false;
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::GitSource($github_app->api_url)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
return $response->successful()
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
} catch (\Throwable) {
return false;
}
}
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
$this->rejectInvalidGithubAppSetupState($request);
}
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', data_get($payload, 'team_id'))
->firstOrFail();
}
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
}