diff --git a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php index 8ab5ee71..bed8b672 100644 --- a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php +++ b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use JsonException; use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Services\GitWebhookIngestionService; @@ -51,12 +52,69 @@ final class GitWebhookController 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 = trim((string) $repository->webhook_secret); + $secret = $this->secretForProvider($repository, $provider); if ($secret === '') { return false; @@ -65,11 +123,26 @@ final class GitWebhookController 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. */ @@ -82,6 +155,18 @@ final class GitWebhookController || 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', ''); + + return hash_equals(hash_hmac('sha256', $rawPayload, $secret), $giteaSignature) + || $this->hasValidGitHubSignature($request, $secret, $rawPayload) + || $this->hasValidGenericSignature($request, $secret, $rawPayload); + } + /** * Validate Bitbucket's webhook HMAC signature. */ @@ -106,6 +191,66 @@ final class GitWebhookController || 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); + } + + /** + * 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) + ->whereIn('provider', ['gitea', 'git']) + ->get(); + + if ($repositories->isEmpty()) { + return null; + } + + $payloadValues = 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) !== ''); + + foreach ($repositories as $repository) { + foreach ($payloadValues as $value) { + $payloadValue = trim((string) $value); + $remoteUrl = trim((string) $repository->remote_url); + + if ($remoteUrl !== '' && ($payloadValue === $remoteUrl || str_contains($remoteUrl, $payloadValue) || str_contains($payloadValue, $remoteUrl))) { + return $repository; + } + + if (strcasecmp($payloadValue, (string) $repository->name) === 0) { + return $repository; + } + } + } + + return $repositories->count() === 1 ? $repositories->first() : null; + } + /** * Read the event name from provider-specific headers. */ @@ -114,6 +259,7 @@ final class GitWebhookController 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', }; @@ -127,6 +273,7 @@ final class GitWebhookController 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'), }; diff --git a/Modules/Helpdesk/Providers/RouteServiceProvider.php b/Modules/Helpdesk/Providers/RouteServiceProvider.php index 9acc652e..80ed00c9 100644 --- a/Modules/Helpdesk/Providers/RouteServiceProvider.php +++ b/Modules/Helpdesk/Providers/RouteServiceProvider.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Providers; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Http\Controllers\GitWebhookController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Support\ModuleInfo; @@ -46,5 +47,11 @@ final class RouteServiceProvider extends ServiceProvider ->middleware('api') ->name('api.helpdesk.') ->group(module_path(ModuleInfo::name(), 'Routes/api.php')); + + Route::prefix('api/git') + ->middleware('api') + ->name('api.git.') + ->post('/gitea/webhook', [GitWebhookController::class, 'gitea']) + ->name('gitea.webhook'); } } diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index 38392698..dfe72123 100644 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -13,3 +13,6 @@ Route::get('/status', static fn (): array => [ Route::post('/git/webhook/{repository}', GitWebhookController::class) ->whereNumber('repository') ->name('git.webhook'); + +Route::post('/git/gitea/webhook', [GitWebhookController::class, 'gitea']) + ->name('git.gitea.webhook'); diff --git a/Modules/Helpdesk/Services/GitWebhookIngestionService.php b/Modules/Helpdesk/Services/GitWebhookIngestionService.php index 06fd3d7d..82c0972f 100644 --- a/Modules/Helpdesk/Services/GitWebhookIngestionService.php +++ b/Modules/Helpdesk/Services/GitWebhookIngestionService.php @@ -174,6 +174,7 @@ final class GitWebhookIngestionService return match ($normalisedProvider) { 'github' => in_array($normalisedEvent, ['push', ''], true), 'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true), + 'gitea' => in_array($normalisedEvent, ['push', 'push hook', ''], true), 'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true), default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true), }; diff --git a/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php index 7658f95f..4faefd41 100644 --- a/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php +++ b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php @@ -160,4 +160,94 @@ final class GitWebhookIngestionTest extends TestCase 'linked_count' => 1, ]); } + + public function test_gitea_local_webhook_tracks_latest_push_without_repository_id(): void + { + config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l'); + + $fixtures = $this->seedLookupData(); + $repository = $this->repository([ + 'provider' => 'gitea', + 'project_id' => $fixtures['project']->id, + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'webhook_secret' => 'gitea-secret', + ]); + $ticket = $this->ticket(['project_id' => $fixtures['project']->id]); + $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('d', 40), + 'message' => $ticket->reference . ' Fix local Gitea webhook ingestion', + 'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('d', 40), + 'timestamp' => '2026-06-24T14:20: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_AUTHORIZATION' => 'Basic YWxhZGRpbjpvcGVuc2VzYW1l', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_DELIVERY' => 'gitea-delivery-123', + 'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('d', 40), + 'branch' => 'main', + ]); + + $this->assertDatabaseHas('helpdesk_git_webhook_logs', [ + 'repository_id' => $repository->id, + 'provider' => 'gitea', + 'delivery_id' => 'gitea-delivery-123', + 'event' => 'push', + 'status' => 'accepted', + 'linked_count' => 1, + ]); + } + + public function test_gitea_local_webhook_rejects_invalid_authorization_without_creating_commits(): void + { + config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l'); + + $this->repository([ + 'provider' => 'gitea', + 'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git', + 'webhook_secret' => 'gitea-secret', + ]); + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'], + 'commits' => [['id' => str_repeat('e', 40), 'message' => 'No auth']], + ]; + $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_AUTHORIZATION' => 'Basic invalid', + 'HTTP_X_GITEA_EVENT' => 'push', + 'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'), + ], $json); + + $response->assertForbidden()->assertJsonPath('status', 'rejected'); + + self::assertSame(0, GitCommit::query()->count()); + } + } diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index c25f205e..cfaadc30 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -453,3 +453,14 @@ - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. - Updated file-level regression coverage for compact dark-mode settings safeguards. - Preserved append-only changelog history. + +- version: 1.10.17 + date: 2026-06-24 + type: bugfix + summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. + changes: + - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. + - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. + - 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. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 5422520a..0a59baeb 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -15,12 +15,13 @@ "git-webhooks", "github", "gitlab", - "audit" + "audit", + "gitea" ], "priority": 0, "providers": [ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.10.16" + "version": "1.10.17" }