402 lines
15 KiB
PHP
Executable File
402 lines
15 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\Helpdesk\Http\Controllers;
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use JsonException;
|
|
use Modules\Helpdesk\Models\GitRepository;
|
|
use Modules\Helpdesk\Services\GitWebhookIngestionService;
|
|
|
|
final class GitWebhookController
|
|
{
|
|
private const REJECTED = 'rejected';
|
|
|
|
private const SIGNATURE_FAILED = 'Git webhook signature or token validation failed.';
|
|
|
|
private const SHA256_PREFIX = 'sha256=';
|
|
|
|
private const SHA1_PREFIX = 'sha1=';
|
|
|
|
private const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
|
|
private const GITHUB_DELIVERY_HEADER = 'X-GitHub-Delivery';
|
|
|
|
private const GITHUB_EVENT_HEADER = 'X-GitHub-Event';
|
|
|
|
/**
|
|
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
|
|
*/
|
|
public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
|
|
{
|
|
$provider = strtolower((string) ($repository->provider ?: 'git'));
|
|
$event = $this->eventName($request, $provider);
|
|
$deliveryId = $this->deliveryId($request, $provider);
|
|
$rawPayload = $request->getContent();
|
|
$status = 202;
|
|
$payload = [];
|
|
|
|
if (! $repository->is_active) {
|
|
$result = $this->rejectedResult('Repository is inactive.');
|
|
$status = 403;
|
|
} elseif (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
|
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
|
|
$status = 403;
|
|
} else {
|
|
try {
|
|
/** @var array<string, mixed> $payload */
|
|
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
|
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
|
} catch (JsonException) {
|
|
$result = $this->rejectedResult('Invalid JSON webhook payload.');
|
|
$status = 400;
|
|
}
|
|
}
|
|
|
|
if ($status !== 202) {
|
|
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
|
|
}
|
|
|
|
return response()->json($result, $status);
|
|
}
|
|
|
|
/**
|
|
* Ingest the local Gitea webhook URL that does not include a repository id.
|
|
*/
|
|
public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse
|
|
{
|
|
$provider = 'gitea';
|
|
$event = $this->eventName($request, $provider);
|
|
$deliveryId = $this->deliveryId($request, $provider);
|
|
$rawPayload = $request->getContent();
|
|
$status = 202;
|
|
$repository = null;
|
|
|
|
try {
|
|
/** @var array<string, mixed> $payload */
|
|
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
|
$repository = $this->repositoryForGiteaPayload($payload);
|
|
|
|
if (! $repository instanceof GitRepository) {
|
|
$result = $this->rejectedResult('No active Helpdesk Git repository matched the Gitea webhook payload.');
|
|
$status = 404;
|
|
} elseif (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
|
|
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
|
|
$status = 403;
|
|
} else {
|
|
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
|
}
|
|
} catch (JsonException) {
|
|
$payload = [];
|
|
$result = $this->rejectedResult('Invalid JSON webhook payload.');
|
|
$status = 400;
|
|
}
|
|
|
|
if ($repository instanceof GitRepository && $status !== 202) {
|
|
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
|
|
}
|
|
|
|
return response()->json($result, $status);
|
|
}
|
|
|
|
/**
|
|
* Validate provider-specific webhook signatures or tokens.
|
|
*/
|
|
private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
|
|
{
|
|
$secret = $this->secretForProvider($repository, $provider);
|
|
|
|
if ($secret === '') {
|
|
return false;
|
|
}
|
|
|
|
return match ($provider) {
|
|
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
|
|
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
|
|
'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload),
|
|
'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
|
|
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the repository webhook secret, with local Gitea env fallback support.
|
|
*/
|
|
private function secretForProvider(GitRepository $repository, string $provider): string
|
|
{
|
|
$secret = trim((string) $repository->webhook_secret);
|
|
|
|
if ($secret !== '') {
|
|
return $secret;
|
|
}
|
|
|
|
return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : '';
|
|
}
|
|
|
|
/**
|
|
* Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
|
|
*/
|
|
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
|
|
{
|
|
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
|
|
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
|
|
|
|
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
|
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
|
}
|
|
|
|
/**
|
|
* Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers.
|
|
*/
|
|
private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool
|
|
{
|
|
$giteaSignature = (string) $request->header('X-Gitea-Signature', '');
|
|
|
|
$sha256 = hash_hmac('sha256', $rawPayload, $secret);
|
|
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
|
|
|
|
return hash_equals($sha256, $giteaSignature)
|
|
|| hash_equals(self::SHA256_PREFIX . $sha256, $giteaSignature)
|
|
|| hash_equals(self::SHA1_PREFIX . $sha1, $giteaSignature)
|
|
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|
|
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
|
|
}
|
|
|
|
/**
|
|
* Validate Bitbucket's webhook HMAC signature.
|
|
*/
|
|
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
|
|
{
|
|
$signature = (string) $request->header('X-Hub-Signature', '');
|
|
|
|
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
|
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
|
|
}
|
|
|
|
/**
|
|
* Validate a generic Git webhook using either a token header or HMAC signature.
|
|
*/
|
|
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
|
|
{
|
|
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token'));
|
|
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
|
|
|
|
return hash_equals($secret, $token)
|
|
|| hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
|
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
|
}
|
|
|
|
/**
|
|
* Validate Gitea webhook security without allowing an optional Authorization
|
|
* header mismatch to hide otherwise valid signed push deliveries.
|
|
*/
|
|
private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
|
|
{
|
|
if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
|
return true;
|
|
}
|
|
|
|
return $this->hasValidGiteaAuthorization($request);
|
|
}
|
|
|
|
/**
|
|
* Validate the optional Authorization header configured for the local Gitea webhook.
|
|
*/
|
|
private function hasValidGiteaAuthorization(Request $request): bool
|
|
{
|
|
$expected = trim((string) config('services.gitea.webhook_authorization', ''));
|
|
|
|
if ($expected === '') {
|
|
return true;
|
|
}
|
|
|
|
$actual = trim((string) $request->header('Authorization', ''));
|
|
|
|
return hash_equals($expected, $actual)
|
|
|| hash_equals('Basic ' . $expected, $actual)
|
|
|| (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual));
|
|
}
|
|
|
|
/**
|
|
* Match a Gitea webhook payload to the configured Helpdesk repository.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function repositoryForGiteaPayload(array $payload): ?GitRepository
|
|
{
|
|
$repositories = GitRepository::query()
|
|
->where('is_active', true)
|
|
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
|
|
->get();
|
|
$matchedRepository = null;
|
|
|
|
if ($repositories->isEmpty()) {
|
|
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
|
|
} else {
|
|
$normalisedPayloadValues = $this->normalisedPayloadRepositoryValues($payload);
|
|
$matchedRepository = $repositories->first(
|
|
fn (GitRepository $repository): bool => $this->repositoryMatchesPayload($repository, $normalisedPayloadValues),
|
|
);
|
|
|
|
if (! $matchedRepository instanceof GitRepository && $repositories->count() === 1) {
|
|
$matchedRepository = $repositories->first();
|
|
} elseif (! $matchedRepository instanceof GitRepository) {
|
|
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
|
|
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
|
|
'configured_repository_count' => $repositories->count(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $matchedRepository instanceof GitRepository ? $matchedRepository : null;
|
|
}
|
|
|
|
/** @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function normalisedPayloadRepositoryValues(array $payload): array
|
|
{
|
|
$name = Arr::get($payload, 'repository.name');
|
|
$owner = Arr::get($payload, 'repository.owner.username');
|
|
|
|
return $this->normaliseRepositoryValues([
|
|
Arr::get($payload, 'repository.full_name'),
|
|
$name,
|
|
Arr::get($payload, 'repository.html_url'),
|
|
Arr::get($payload, 'repository.clone_url'),
|
|
Arr::get($payload, 'repository.ssh_url'),
|
|
Arr::get($payload, 'repository.url'),
|
|
$owner && $name ? $owner . '/' . $name : null,
|
|
]);
|
|
}
|
|
|
|
/** @param list<string> $payloadValues */
|
|
private function repositoryMatchesPayload(GitRepository $repository, array $payloadValues): bool
|
|
{
|
|
$repositoryValues = $this->normaliseRepositoryValues([
|
|
$repository->remote_url,
|
|
$repository->name,
|
|
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
|
|
]);
|
|
|
|
foreach ($payloadValues as $payloadValue) {
|
|
foreach ($repositoryValues as $repositoryValue) {
|
|
if ($this->repositoryIdentitiesMatch($payloadValue, $repositoryValue)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @param array<int, mixed> $values
|
|
* @return list<string>
|
|
*/
|
|
private function normaliseRepositoryValues(array $values): array
|
|
{
|
|
$normalised = [];
|
|
|
|
foreach ($values as $value) {
|
|
if (! is_scalar($value) || trim((string) $value) === '') {
|
|
continue;
|
|
}
|
|
|
|
$identity = $this->normaliseRepositoryIdentity((string) $value);
|
|
if ($identity !== null) {
|
|
$normalised[] = $identity;
|
|
}
|
|
}
|
|
|
|
return $normalised;
|
|
}
|
|
|
|
private function repositoryIdentitiesMatch(string $payloadValue, string $repositoryValue): bool
|
|
{
|
|
return $payloadValue === $repositoryValue
|
|
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|
|
|| str_ends_with($payloadValue, '/' . $repositoryValue);
|
|
}
|
|
|
|
/**
|
|
* Normalise a repository name or URL so Gitea payloads match local settings reliably.
|
|
*/
|
|
private function normaliseRepositoryIdentity(string $value): ?string
|
|
{
|
|
$value = trim($value);
|
|
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
$value = Str::of($value)
|
|
->replace('\\', '/')
|
|
->replace('ssh://git@', 'ssh://')
|
|
->replace('git@', '')
|
|
->replace('.git', '')
|
|
->lower()
|
|
->value();
|
|
|
|
$path = parse_url($value, PHP_URL_PATH);
|
|
|
|
if (is_string($path) && trim($path, '/') !== '') {
|
|
$value = trim($path, '/');
|
|
} elseif (str_contains($value, ':')) {
|
|
$value = Str::of($value)->afterLast(':')->trim('/')->value();
|
|
}
|
|
|
|
$segments = array_values(array_filter(explode('/', trim($value, '/'))));
|
|
|
|
if ($segments === []) {
|
|
return null;
|
|
}
|
|
|
|
return implode('/', array_slice($segments, -2));
|
|
}
|
|
|
|
/** @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} */
|
|
private function rejectedResult(string $message): array
|
|
{
|
|
return [
|
|
'status' => self::REJECTED,
|
|
'message' => $message,
|
|
'commit_count' => 0,
|
|
'linked_count' => 0,
|
|
'ignored_count' => 1,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Read the event name from provider-specific headers.
|
|
*/
|
|
private function eventName(Request $request, string $provider): ?string
|
|
{
|
|
return match ($provider) {
|
|
'github' => $request->header(self::GITHUB_EVENT_HEADER),
|
|
'gitlab' => $request->header('X-Gitlab-Event'),
|
|
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header(self::GITHUB_EVENT_HEADER),
|
|
'bitbucket' => $request->header('X-Event-Key'),
|
|
default => $request->header('X-Helpdesk-Event') ?: $request->header(self::GITHUB_EVENT_HEADER) ?: $request->header('X-Gitlab-Event') ?: 'push',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read the provider delivery/request id used for idempotent audit tracing.
|
|
*/
|
|
private function deliveryId(Request $request, string $provider): ?string
|
|
{
|
|
return match ($provider) {
|
|
'github' => $request->header(self::GITHUB_DELIVERY_HEADER),
|
|
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
|
|
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
|
|
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
|
|
default => $request->header('X-Helpdesk-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
|
|
};
|
|
}
|
|
}
|