provider ?: 'git')); $event = $this->eventName($request, $provider); $deliveryId = $this->deliveryId($request, $provider); $rawPayload = $request->getContent(); if (! $repository->is_active) { $result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; $service->logWebhook($repository, $provider, $deliveryId, $event, $result); return response()->json($result, 403); } if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) { $result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; $service->logWebhook($repository, $provider, $deliveryId, $event, $result); return response()->json($result, 403); } try { /** @var array $payload */ $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { $result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; $service->logWebhook($repository, $provider, $deliveryId, $event, $result); return response()->json($result, 400); } $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); return response()->json($result, 202); } /** * 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(); if (! $this->hasValidGiteaAuthorization($request)) { return response()->json([ 'status' => 'rejected', 'message' => 'Gitea webhook authorization failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ], 403); } try { /** @var array $payload */ $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { return response()->json([ 'status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ], 400); } $repository = $this->repositoryForGiteaPayload($payload); if (! $repository instanceof GitRepository) { return response()->json([ 'status' => 'rejected', 'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ], 404); } if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) { $result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; $service->logWebhook($repository, $provider, $deliveryId, $event, $result); return response()->json($result, 403); } $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); return response()->json($result, 202); } /** * 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('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) || hash_equals('sha1=' . 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('sha256=' . $sha256, $giteaSignature) || hash_equals('sha1=' . $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('sha256=' . 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('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature); } /** * 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 $payload */ private function repositoryForGiteaPayload(array $payload): ?GitRepository { $repositories = GitRepository::query() ->where('is_active', true) ->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git']) ->get(); if ($repositories->isEmpty()) { Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.'); return null; } $payloadValues = array_values(array_filter([ Arr::get($payload, 'repository.full_name'), Arr::get($payload, 'repository.name'), Arr::get($payload, 'repository.html_url'), Arr::get($payload, 'repository.clone_url'), Arr::get($payload, 'repository.ssh_url'), Arr::get($payload, 'repository.url'), Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name') ? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name') : null, ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '')); $normalisedPayloadValues = array_values(array_filter(array_map( fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), $payloadValues, ))); foreach ($repositories as $repository) { $repositoryValues = array_filter([ $repository->remote_url, $repository->name, Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(), ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''); $normalisedRepositoryValues = array_values(array_filter(array_map( fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), $repositoryValues, ))); foreach ($normalisedPayloadValues as $payloadValue) { foreach ($normalisedRepositoryValues as $repositoryValue) { if ($payloadValue === $repositoryValue || str_ends_with($repositoryValue, '/' . $payloadValue) || str_ends_with($payloadValue, '/' . $repositoryValue)) { return $repository; } } } } if ($repositories->count() === 1) { return $repositories->first(); } 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 null; } /** * 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)); } /** * Read the event name from provider-specific headers. */ private function eventName(Request $request, string $provider): ?string { return match ($provider) { 'github' => $request->header('X-GitHub-Event'), 'gitlab' => $request->header('X-Gitlab-Event'), 'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'), 'bitbucket' => $request->header('X-Event-Key'), default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $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('X-GitHub-Delivery'), 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'), 'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), 'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'), default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), }; } }