using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using ControlPlane.Core.Messages; using MassTransit; namespace ControlPlane.Api.Endpoints; public static class GiteaWebhookEndpoints { // Map of Gitea repo name → solution file (relative to cloned repo root) private static readonly Dictionary RepoSolutions = new(StringComparer.OrdinalIgnoreCase) { ["OPC"] = "ControlPlane.slnx", ["Clarity"] = "Clarity.slnx", }; public static IEndpointRouteBuilder MapGiteaWebhookEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/api/hooks/gitea/push", HandlePushAsync) .WithName("Gitea:PushWebhook") .AllowAnonymous(); // validated via HMAC below return app; } private static async Task HandlePushAsync( HttpRequest request, IPublishEndpoint bus, IConfiguration config, ILogger logger, CancellationToken ct) { // ── HMAC-SHA256 signature validation ───────────────────────────────── var secret = config["Gitea:WebhookSecret"]; if (!string.IsNullOrWhiteSpace(secret)) { if (!request.Headers.TryGetValue("X-Gitea-Signature", out var sigHeader)) return Results.Unauthorized(); var body = await ReadBodyAsync(request, ct); var expectedSig = ComputeHmacSha256(body, secret); if (!CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(sigHeader.ToString()), Encoding.ASCII.GetBytes(expectedSig))) { logger.LogWarning("Gitea push webhook: HMAC signature mismatch — ignoring."); return Results.Unauthorized(); } var payload = JsonSerializer.Deserialize(body, JsonOpts); return await ProcessPayloadAsync(payload, bus, logger, ct); } else { // No secret configured — accept without validation (dev only) logger.LogWarning("Gitea:WebhookSecret is not configured — webhook signature validation is disabled."); var payload = await JsonSerializer.DeserializeAsync( request.Body, JsonOpts, ct); return await ProcessPayloadAsync(payload, bus, logger, ct); } } private static async Task ProcessPayloadAsync( GiteaPushPayload? payload, IPublishEndpoint bus, ILogger logger, CancellationToken ct) { if (payload is null) return Results.BadRequest("Could not parse push payload."); // Only act on pushes to develop if (payload.Ref != "refs/heads/develop") { logger.LogDebug("Gitea push webhook: ignoring push to {Ref}", payload.Ref); return Results.Ok(new { skipped = true, reason = "not develop" }); } var repoName = payload.Repository?.Name; if (string.IsNullOrWhiteSpace(repoName) || !RepoSolutions.TryGetValue(repoName, out var solutionPath)) { logger.LogWarning("Gitea push webhook: unknown or unsupported repo '{Repo}'", repoName); return Results.Ok(new { skipped = true, reason = "unsupported repo" }); } var headSha = payload.After; if (string.IsNullOrWhiteSpace(headSha) || headSha == "0000000000000000000000000000000000000000") { logger.LogInformation("Gitea push webhook: branch deleted — nothing to build."); return Results.Ok(new { skipped = true, reason = "branch deleted" }); } logger.LogInformation( "Gitea push webhook: queuing build for {Repo}@{Sha} ({Branch})", repoName, headSha[..8], payload.Ref); await bus.Publish(new BuildRequestedCommand { RepoName = repoName, HeadSha = headSha, Branch = "develop", SolutionPath = solutionPath, }, ct); return Results.Accepted(value: new { queued = true, repo = repoName, sha = headSha[..8] }); } // ── Helpers ─────────────────────────────────────────────────────────────── private static async Task ReadBodyAsync(HttpRequest request, CancellationToken ct) { request.EnableBuffering(); using var ms = new MemoryStream(); await request.Body.CopyToAsync(ms, ct); request.Body.Position = 0; return ms.ToArray(); } private static string ComputeHmacSha256(byte[] body, string secret) { var key = Encoding.UTF8.GetBytes(secret); var hash = HMACSHA256.HashData(key, body); return Convert.ToHexString(hash).ToLowerInvariant(); } private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); // Marker type for logger category private sealed class GiteaWebhookEndpointsMarker; } // ── Payload DTOs ────────────────────────────────────────────────────────────── public sealed class GiteaPushPayload { [JsonPropertyName("ref")] public string Ref { get; init; } = string.Empty; [JsonPropertyName("before")] public string Before { get; init; } = string.Empty; [JsonPropertyName("after")] public string After { get; init; } = string.Empty; [JsonPropertyName("repository")] public GiteaPushRepo? Repository { get; init; } } public sealed class GiteaPushRepo { [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; [JsonPropertyName("full_name")] public string FullName { get; init; } = string.Empty; }