diff --git a/Modules/Helpdesk/Models/GitCommit.php b/Modules/Helpdesk/Models/GitCommit.php index 637aa4fa..60e7ea33 100644 --- a/Modules/Helpdesk/Models/GitCommit.php +++ b/Modules/Helpdesk/Models/GitCommit.php @@ -4,33 +4,43 @@ declare(strict_types=1); namespace Modules\Helpdesk\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; final class GitCommit extends Model { - use HasFactory; - - protected $table = 'helpdesk_git_commits'; - - /** @var list */ + /** + * @var array + */ protected $fillable = [ - 'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at', + 'repository_full_name', + 'repository_name', + 'ticket_id', + 'sha', + 'branch', + 'author_name', + 'author_email', + 'message', + 'commit_url', + 'committed_at', + 'payload', ]; - /** @var array */ - protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime']; + /** + * @var array + */ + protected $casts = [ + 'payload' => 'array', + 'committed_at' => 'datetime', + ]; - /** @return BelongsTo */ - public function repository(): BelongsTo - { - return $this->belongsTo(GitRepository::class, 'repository_id'); - } - - /** @return BelongsTo */ + /** + * Related helpdesk ticket. + */ public function ticket(): BelongsTo { - return $this->belongsTo(Ticket::class, 'ticket_id'); + return $this->belongsTo( + Ticket::class, + ); } } diff --git a/app/Http/Controllers/Git/GiteaWebhookController.php b/app/Http/Controllers/Git/GiteaWebhookController.php index ccb33642..cd5b5cad 100644 --- a/app/Http/Controllers/Git/GiteaWebhookController.php +++ b/app/Http/Controllers/Git/GiteaWebhookController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Git; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; final class GiteaWebhookController extends Controller @@ -18,21 +19,40 @@ final class GiteaWebhookController extends Controller { Log::info('Gitea webhook endpoint hit.', [ 'event' => $request->header('X-Gitea-Event'), + 'github_event' => $request->header('X-GitHub-Event'), 'delivery' => $request->header('X-Gitea-Delivery'), 'authorization_present' => $request->headers->has('Authorization'), 'signature_present' => $request->headers->has('X-Gitea-Signature'), ]); - $event = (string) $request->header('X-Gitea-Event'); + + $authResponse = $this->validateAuthorization($request); + + if ($authResponse instanceof JsonResponse) { + return $authResponse; + } + + $signatureResponse = $this->validateSignature($request); + + if ($signatureResponse instanceof JsonResponse) { + return $signatureResponse; + } + + $event = (string) ($request->header('X-Gitea-Event') ?: $request->header('X-GitHub-Event')); $payload = $request->json()->all(); if ($event !== 'push') { + Log::info('Gitea webhook ignored.', [ + 'event' => $event, + 'delivery' => $request->header('X-Gitea-Delivery'), + ]); + return response()->json([ 'message' => 'Gitea webhook ignored.', 'event' => $event, ]); } - $commits = $request->input('commits', []); + $commits = data_get($payload, 'commits', []); Log::info('Gitea push webhook received.', [ 'repository' => data_get($payload, 'repository.full_name'), @@ -40,20 +60,36 @@ final class GiteaWebhookController extends Controller 'branch' => str_replace('refs/heads/', '', (string) data_get($payload, 'ref')), 'before' => data_get($payload, 'before'), 'after' => data_get($payload, 'after'), - 'commit_count' => count($commits), + 'commit_count' => is_countable($commits) ? count($commits) : 0, ]); foreach ($commits as $commit) { - Log::info('Gitea commit received.', [ + GitCommit::updateOrCreate( + [ + 'sha' => data_get($commit, 'id'), + ], + [ + 'repository_full_name' => data_get($payload, 'repository.full_name'), + 'repository_name' => data_get($payload, 'repository.name'), + 'branch' => str_replace( + 'refs/heads/', + '', + (string) data_get($payload, 'ref') + ), + 'author_name' => data_get($commit, 'author.name'), + 'author_email' => data_get($commit, 'author.email'), + 'message' => data_get($commit, 'message'), + 'commit_url' => data_get($commit, 'url'), + 'committed_at' => data_get($commit, 'timestamp') + ? Carbon::parse(data_get($commit, 'timestamp')) + : now(), + 'payload' => $commit, + ] + ); + + Log::info('Git commit stored.', [ 'sha' => data_get($commit, 'id'), 'message' => data_get($commit, 'message'), - 'url' => data_get($commit, 'url'), - 'author_name' => data_get($commit, 'author.name'), - 'author_email' => data_get($commit, 'author.email'), - 'timestamp' => data_get($commit, 'timestamp'), - 'added' => data_get($commit, 'added', []), - 'modified' => data_get($commit, 'modified', []), - 'removed' => data_get($commit, 'removed', []), ]); } @@ -61,7 +97,66 @@ final class GiteaWebhookController extends Controller 'message' => 'Gitea push processed.', 'repository' => data_get($payload, 'repository.full_name'), 'branch' => str_replace('refs/heads/', '', (string) data_get($payload, 'ref')), - 'commit_count' => count($commits), + 'commit_count' => is_countable($commits) ? count($commits) : 0, ]); } + + /** + * Validate the optional Gitea Authorization header. + */ + private function validateAuthorization(Request $request): ?JsonResponse + { + $expected = (string) config('services.gitea.webhook_authorization'); + + if ($expected === '') { + return null; + } + + $actual = (string) $request->header('Authorization'); + + if (hash_equals($expected, $actual)) { + return null; + } + + Log::warning('Rejected Gitea webhook because Authorization header was invalid.', [ + 'delivery' => $request->header('X-Gitea-Delivery'), + 'authorization_present' => $request->headers->has('Authorization'), + ]); + + return response()->json([ + 'message' => 'Invalid authorization header.', + ], 403); + } + + /** + * Validate the optional Gitea HMAC SHA-256 signature. + */ + private function validateSignature(Request $request): ?JsonResponse + { + $secret = (string) config('services.gitea.webhook_secret'); + + if ($secret === '') { + return null; + } + + $payload = $request->getContent(); + $signature = (string) $request->header('X-Gitea-Signature'); + + $expected = hash_hmac('sha256', $payload, $secret); + + if ($signature !== '' && hash_equals($expected, $signature)) { + return null; + } + + Log::warning('Rejected Gitea webhook because signature was invalid.', [ + 'delivery' => $request->header('X-Gitea-Delivery'), + 'signature_present' => $request->headers->has('X-Gitea-Signature'), + 'expected' => $expected, + 'actual' => $signature, + ]); + + return response()->json([ + 'message' => 'Invalid Gitea signature.', + ], 403); + } } diff --git a/database/migrations/2026_06_24_121904_create_commit_table.php b/database/migrations/2026_06_24_121904_create_commit_table.php new file mode 100644 index 00000000..85c2ce16 --- /dev/null +++ b/database/migrations/2026_06_24_121904_create_commit_table.php @@ -0,0 +1,53 @@ + +id(); + + $table->string('repository_full_name')->index(); + $table->string('repository_name')->nullable(); + + $table->unsignedBigInteger('ticket_id')->nullable(); + + $table->string('sha', 64)->unique(); + $table->string('branch')->nullable(); + + $table->string('author_name')->nullable(); + $table->string('author_email')->nullable(); + + $table->text('message'); + + $table->string('commit_url')->nullable(); + + $table->timestamp('committed_at')->nullable(); + + $table->json('payload')->nullable(); + + $table->timestamps(); + + $table->foreign('ticket_id') + ->references('id') + ->on('helpdesk_tickets') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('git_commits'); + } +};