Files
coolify/bootstrap/helpers/github.php
T
Andras Bacsai b7a2c75739 fix(github): check clock skew only from failed GitHub App responses
Stop calling /zen before each installation token request. A short
network problem to GitHub failed deployments with a cURL timeout on
/zen. assertGithubClockInSync now takes the failed response and reads
its Date header, so the "System time is out of sync" error still shows
when GitHub rejects the JWT.

Call it on failed JWT requests in the webhook installation check, the
permission job, the source connection test, and the server transfer
checklist. Remove the /zen fakes from the tests.
2026-11-03 19:29:45 +01:00

505 lines
16 KiB
PHP

<?php
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder;
/**
* Extract and normalize the hostname from a GitHub URL.
*
* @param string|null $url The URL to parse
* @return string|null The lowercase hostname, or null if the URL is blank or has no parseable host
*/
function githubUrlHost(?string $url): ?string
{
if (blank($url)) {
return null;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || blank($host)) {
return null;
}
return strtolower($host);
}
/**
* Build the scheme://host[:port] origin for a GitHub URL.
*
* This helper fails explicitly for blank, scheme-less, or malformed input when
* githubUrlHost() cannot parse a host, because returning the original input
* would not be a valid origin. Callers should pass already-validated URLs.
*
* @param string $url The URL to derive the origin from
* @return string The normalized origin
*
* @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
*/
function githubUrlOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || blank($scheme) || ! $host) {
throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
}
/**
* Determine whether the URL points at github.com.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubDotComHost(?string $htmlUrl): bool
{
return githubUrlHost($htmlUrl) === 'github.com';
}
/**
* Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGheDotComHost(?string $htmlUrl): bool
{
$host = githubUrlHost($htmlUrl);
return is_string($host)
&& Str::endsWith($host, '.ghe.com')
&& ! Str::startsWith($host, 'api.');
}
/**
* Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com).
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubCloudFamilyHost(?string $htmlUrl): bool
{
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
}
/**
* Determine whether the URL belongs to a self-hosted GitHub Enterprise Server.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
{
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
}
/**
* Derive the GitHub REST API base URL from a GitHub HTML URL.
*
* @param string $htmlUrl The GitHub HTML URL
* @return string The API base URL (api.github.com, api.<host> for *.ghe.com, or <origin>/api/v3 for GHES)
*/
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
{
if (isGithubDotComHost($htmlUrl)) {
return 'https://api.github.com';
}
if (isGheDotComHost($htmlUrl)) {
return 'https://api.'.githubUrlHost($htmlUrl);
}
return githubUrlOrigin($htmlUrl).'/api/v3';
}
/**
* Normalize a GitHub organization slug by trimming surrounding slashes and whitespace.
*
* @param string|null $organization The raw organization value
* @return string|null The trimmed organization, or null when blank
*/
function normalizeGithubOrganization(?string $organization): ?string
{
if (blank($organization)) {
return null;
}
return trim((string) $organization, "/ \t\n\r\0\x0B");
}
/**
* URL-encode a single GitHub path segment.
*
* @param string $segment The raw path segment
* @return string The raw-URL-encoded segment
*/
function encodeGithubPathSegment(string $segment): string
{
return rawurlencode($segment);
}
/**
* GitHub rejects an App JWT when the clocks of Coolify and GitHub differ.
* Call this with the failed response of a JWT request: its Date header shows the difference without an extra request.
*/
function assertGithubClockInSync(Response $response): void
{
$date = $response->header('Date');
if (blank($date)) {
return;
}
try {
$githubTime = Carbon::parse($date);
} catch (Throwable) {
return;
}
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
if ($timeDiff > 50) {
throw new Exception(
'System time is out of sync with GitHub API time:<br>'.
'- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC<br>'.
'- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC<br>'.
'- Difference: '.$timeDiff.' seconds<br>'.
'Please synchronize your system clock.'
);
}
}
function githubAppPrivateKey(GithubApp $source): ?PrivateKey
{
$privateKey = $source->privateKey;
return $privateKey?->team_id === $source->team_id ? $privateKey : null;
}
function generateGithubToken(GithubApp $source, string $type)
{
$privateKey = githubAppPrivateKey($source) ?? throw new RuntimeException('Private key not found for this GitHub App.');
$signingKey = InMemory::plainText($privateKey->private_key);
$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'));
$jwt = $tokenBuilder
->issuedBy($source->app_id)
->issuedAt($now->modify('-1 minute'))
->expiresAt($now->modify('+8 minutes'))
->getToken($algorithm, $signingKey)
->toString();
return match ($type) {
'jwt' => $jwt,
'installation' => (function () use ($source, $jwt) {
// GitHub sometimes answers with a 5xx page or drops the connection; a new token request is harmless.
$response = Http::GitSource($source->api_url)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github.machine-man-preview+json',
])->retry(
3,
fn (int $attempt) => $attempt * 1000,
fn (Throwable $exception) => $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError()),
throw: false,
)->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens");
if (! $response->successful()) {
assertGithubClockInSync($response);
$error = data_get($response->json(), 'message', "HTTP {$response->status()}");
if ($error === 'Not Found') {
$error = "GitHub App installation {$source->installation_id} was not found. Install the GitHub App again or update its installation ID.";
}
throw new RuntimeException("Failed to get installation token for {$source->name} with error: ".$error);
}
return $response->json()['token'];
})(),
default => throw new InvalidArgumentException("Unsupported token type: {$type}")
};
}
function generateGithubInstallationToken(GithubApp $source)
{
return $source->installationToken();
}
function generateGithubJwt(GithubApp $source)
{
return generateGithubToken($source, 'jwt');
}
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
{
if (is_null($source)) {
throw new Exception('Source is required for API calls');
}
if ($source->getMorphClass() !== GithubApp::class) {
throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
}
if ($source->is_public) {
$response = Http::GitHub($source->api_url)->$method($endpoint);
} else {
$token = generateGithubInstallationToken($source);
if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) {
$response = Http::GitHub($source->api_url, $token)->$method($endpoint, $data);
} else {
$response = Http::GitHub($source->api_url, $token)->$method($endpoint);
}
}
if (! $response->successful() && $throwError) {
$resetTime = Carbon::parse((int) $response->header('X-RateLimit-Reset'))->format('Y-m-d H:i:s');
$errorMessage = data_get($response->json(), 'message', 'no error message found');
$remainingCalls = $response->header('X-RateLimit-Remaining', '0');
throw new Exception(
'GitHub API call failed:<br>'.
"Error: {$errorMessage}<br>".
'Rate Limit Status:<br>'.
"- Remaining Calls: {$remainingCalls}<br>".
"- Reset Time: {$resetTime} UTC"
);
}
return [
'rate_limit_remaining' => $response->header('X-RateLimit-Remaining'),
'rate_limit_reset' => $response->header('X-RateLimit-Reset'),
'data' => collect($response->json()),
];
}
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 = githubAppPrivateKey($source);
if (! $privateKey) {
return null;
}
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
$response = Http::GitSource($source->api_url)->withHeaders([
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
'Authorization' => "Bearer {$jwt}",
])->get("{$source->api_url}/app");
if (! $response->successful()) {
assertGithubClockInSync($response);
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
{
$name = encodeGithubPathSegment(Str::kebab($source->name));
$state = Str::random(64);
$organization = normalizeGithubOrganization($source->organization);
if (isGithubEnterpriseServerHost($source->html_url)) {
$path = "github-apps/{$name}";
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
} else {
$path = "apps/{$name}";
}
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => 'install',
'github_app_id' => $source->id,
'team_id' => $source->team_id,
], now()->addMinutes(60));
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
}
function getPermissionsPath(GithubApp $source)
{
$name = encodeGithubPathSegment(Str::kebab($source->name));
$organization = normalizeGithubOrganization($source->organization);
if (filled($organization)) {
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
}
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
}
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
{
$response = Http::GitHub($source->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
$json = $response->json();
if ($response->status() !== 200) {
$error = data_get($json, 'message', "HTTP {$response->status()}");
throw new RuntimeException("Failed to load repositories for {$source->name} with error: ".$error);
}
if ($json['total_count'] === 0) {
return [
'total_count' => 0,
'repositories' => [],
];
}
return [
'total_count' => $json['total_count'],
'repositories' => $json['repositories'],
];
}
/**
* Returns null when the full list of changed files is unknown (API error or truncated list).
*/
function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $repo, string $beforeSha, string $afterSha): ?array
{
try {
if (! $source) {
// Manual webhooks don't have GitHub App authentication
return [];
}
$endpoint = "/repos/{$owner}/{$repo}/compare/{$beforeSha}...{$afterSha}";
$response = githubApi($source, $endpoint);
$files = collect(data_get($response, 'data.files', []));
// The compare API returns at most 300 files and cannot paginate them.
if ($files->count() >= 300) {
return null;
}
// A renamed file also changes its previous path.
return $files->flatMap(fn (mixed $file): array => [data_get($file, 'filename'), data_get($file, 'previous_filename')])
->filter(fn (mixed $filename): bool => is_string($filename) && $filename !== '')
->unique()
->values()
->toArray();
} catch (Exception $e) {
return null;
}
}
function getGithubCommitMessage(?GithubApp $source, string $owner, string $repo, string $commitSha): ?string
{
try {
if (! $source) {
return null;
}
if (blank($owner) || blank($repo) || blank($commitSha) || $commitSha === 'HEAD') {
return null;
}
$endpoint = "/repos/{$owner}/{$repo}/commits/{$commitSha}";
$response = githubApi($source, $endpoint, 'get', null, false);
$message = data_get($response, 'data.commit.message');
return is_string($message) ? $message : null;
} catch (Exception $e) {
return null;
}
}
/**
* Returns null when the full list of changed files is unknown (API error or truncated list).
*/
function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): ?array
{
try {
if (! $source) {
// Manual webhooks don't have GitHub App authentication
return [];
}
// GitHub returns at most 100 files per page and 3000 files per pull request.
$perPage = 100;
$maxPages = 30;
$files = collect();
for ($page = 1; $page <= $maxPages; $page++) {
$endpoint = "/repos/{$owner}/{$repo}/pulls/{$pullRequestId}/files?per_page={$perPage}&page={$page}";
$pageFiles = collect(data_get(githubApi($source, $endpoint), 'data', []));
$files = $files->merge($pageFiles->pluck('filename'));
if ($pageFiles->count() < $perPage) {
return $files->filter()->values()->toArray();
}
}
return null;
} catch (Exception $e) {
return null;
}
}