new git integraton code
This commit is contained in:
@@ -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<string, mixed> $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<string, mixed> $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'),
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user