diff --git a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php index bed8b672..d957f647 100644 --- a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php +++ b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php @@ -7,6 +7,8 @@ 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; @@ -162,7 +164,12 @@ final class GitWebhookController { $giteaSignature = (string) $request->header('X-Gitea-Signature', ''); - return hash_equals(hash_hmac('sha256', $rawPayload, $secret), $giteaSignature) + $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); } @@ -183,7 +190,7 @@ final class GitWebhookController */ private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool { - $token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token')); + $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) @@ -205,7 +212,8 @@ final class GitWebhookController $actual = trim((string) $request->header('Authorization', '')); return hash_equals($expected, $actual) - || hash_equals('Basic ' . $expected, $actual); + || hash_equals('Basic ' . $expected, $actual) + || (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual)); } /** @@ -217,38 +225,101 @@ final class GitWebhookController { $repositories = GitRepository::query() ->where('is_active', true) - ->whereIn('provider', ['gitea', 'git']) + ->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_filter([ + $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'), - ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''); + 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) { - foreach ($payloadValues as $value) { - $payloadValue = trim((string) $value); - $remoteUrl = trim((string) $repository->remote_url); + $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) !== ''); - if ($remoteUrl !== '' && ($payloadValue === $remoteUrl || str_contains($remoteUrl, $payloadValue) || str_contains($payloadValue, $remoteUrl))) { - return $repository; - } + $normalisedRepositoryValues = array_values(array_filter(array_map( + fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), + $repositoryValues, + ))); - if (strcasecmp($payloadValue, (string) $repository->name) === 0) { - return $repository; + foreach ($normalisedPayloadValues as $payloadValue) { + foreach ($normalisedRepositoryValues as $repositoryValue) { + if ($payloadValue === $repositoryValue + || str_ends_with($repositoryValue, '/' . $payloadValue) + || str_ends_with($payloadValue, '/' . $repositoryValue)) { + return $repository; + } } } } - return $repositories->count() === 1 ? $repositories->first() : null; + 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)); } /** diff --git a/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php index 4faefd41..f28b0bbd 100644 --- a/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php +++ b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php @@ -250,4 +250,51 @@ final class GitWebhookIngestionTest extends TestCase self::assertSame(0, GitCommit::query()->count()); } + + public function test_gitea_local_webhook_accepts_prefixed_signature_and_case_insensitive_provider(): void + { + $fixtures = $this->seedLookupData(); + $repository = $this->repository([ + 'provider' => 'Gitea', + 'project_id' => $fixtures['project']->id, + 'name' => 'Local', + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core', + 'webhook_secret' => 'gitea-secret', + ]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => [ + 'name' => 'WMS-Core', + 'full_name' => 'Admin/WMS-Core', + 'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'html_url' => 'http://git.vm:3000/Admin/WMS-Core', + ], + 'commits' => [ + [ + 'id' => str_repeat('f', 40), + 'message' => 'Local Gitea push should be visible on the dashboard', + 'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('f', 40), + 'timestamp' => '2026-06-24T15:00:00+00:00', + 'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'], + ], + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_SIGNATURE' => 'sha256=' . hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'hash' => str_repeat('f', 40), + 'branch' => 'main', + ]); + } + } diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index cfaadc30..65808829 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -464,3 +464,10 @@ - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. - Preserved append-only changelog history. + +- version: 1.10.18 + date: 2026-06-24 + changes: + - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. + - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. + - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 0a59baeb..e71ee9b3 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -23,5 +23,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.10.17" + "version": "1.10.18" }