Files

912 lines
44 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 App\Services\ServerTransfer\TransferFinishChecklist;
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\Facades\Log;
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,
'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;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$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,
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);
}
}
/**
* Cuts a webhook string to the varchar(255) column of the runner tables.
*/
private function limitRunnerColumn(mixed $value): ?string
{
return is_scalar($value) ? mb_substr((string) $value, 0, 255) : null;
}
/**
* 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.');
}
// The columns are varchar(255). A cut URL would be a broken link, so a longer one is not stored.
$htmlUrl = data_get($payload, 'workflow_job.html_url');
$jobDetails = array_filter([
'workflow_job_id' => $jobId,
'workflow_job_html_url' => is_string($htmlUrl) && mb_strlen($htmlUrl) <= 255 ? $htmlUrl : null,
'workflow_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.workflow_name')),
'job_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.name')),
'repository_full_name' => $this->limitRunnerColumn(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;
// The completed hook can arrive before the in_progress hook.
if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
$replacement = $execution->assignJob($jobId, $jobDetails);
if ($replacement) {
ProvisionGithubRunnerJob::dispatch($replacement->id);
}
if ($action === 'in_progress') {
return response('Runner marked running.');
}
}
if ($action === 'completed') {
if ($execution) {
$execution->update([...$jobDetails, 'conclusion' => $this->limitRunnerColumn(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');
}
// One GitHub App can be installed on several accounts, each added as its own source.
$installation_id = data_get($payload, 'installation.id');
$github_app = is_numeric($installation_id)
? GithubApp::where('app_id', $x_github_hook_installation_target_id)->where('installation_id', (int) $installation_id)->first()
: null;
$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 (! 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.');
}
TransferFinishChecklist::recordWebhook('github_app', $github_app->uuid);
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,
'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)
->where('source_type', GithubApp::class)
->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 ($applications->contains(fn (Application $application): bool => filled($application->watch_paths))) {
$changed_files = $this->githubPushComparedFiles($github_app, $payload) ?? $changed_files;
}
}
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,
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);
}
}
/**
* Files that the push changes between its before and after commits, from
* the GitHub compare API.
*
* The file lists of the pushed commits are not the net change: a merge
* commit names the files that it changes relative to its first parent,
* also when the push does not change them. Returns null for a new branch,
* a missing repository name or an API failure; the caller then uses the
* file lists of the pushed commits.
*
* @return Collection<int, string>|null
*
* @throws InvalidWebhookPayloadException When the before or after value is not a commit SHA.
*/
private function githubPushComparedFiles(GithubApp $githubApp, mixed $payload): ?Collection
{
$before = $this->webhookCommitSha($payload, 'before');
$after = $this->webhookCommitSha($payload, 'after');
$fullName = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
if ($before === null || $after === null || $fullName === null || preg_match('/\A0+\z/', $before) === 1) {
return null;
}
[$owner, $repo] = explode('/', $fullName, 2);
$files = getGithubCommitRangeFiles($githubApp, $owner, $repo, $before, $after);
return $files === null ? null : collect($files);
}
/**
* 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, 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',
'is_fork_pull_request' => $this->isForkPullRequest($payload),
];
}
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* A same-repository PR branch can only be pushed by users with write access,
* so this is the trust signal for preview deployments. GitHub's
* `author_association` is not reliable for this: users with access through a
* team or a private organization membership arrive as CONTRIBUTOR or NONE.
*
* 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. A PR whose head repository is unknown (e.g.
* a deleted fork) is treated as a fork.
*
* @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 true;
}
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)
->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',
);
$verification_error = $this->githubInstallationVerificationError($github_app, $installation_id);
if ($verification_error) {
Log::warning('GitHub App installation could not be verified.', [
'github_app_id' => $github_app->id,
'installation_id' => $installation_id,
'reason' => $verification_error,
]);
$message = "GitHub App installation could not be verified: {$verification_error}";
abort_if($request->expectsJson(), 403, $message);
return redirect()
->route('source.github.show', ['github_app_uuid' => $github_app->uuid])
->with('error', $message);
}
$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.
*
* @return string|null The reason the verification failed, or null when it passed.
*/
private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string
{
if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) {
return 'The GitHub App ID or private key is missing.';
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::GitSource($github_app->api_url)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
} catch (\Throwable $e) {
return strip_tags(str_replace('<br>', ' ', $e->getMessage()));
}
if (! $response->successful()) {
return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found');
}
if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) {
return 'The installation belongs to a different GitHub App.';
}
return null;
}
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);
}
// The state is bound to the team that started the setup. The user may have switched
// teams while on GitHub, so check membership of that team, not the session team.
$team_id = (int) data_get($payload, 'team_id');
abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403);
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', $team_id)
->firstOrFail();
if ($request->user()->currentTeam()?->id !== $team_id) {
refreshSession($github_app->team);
}
return $github_app;
}
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);
}
}