Files
OPC/ControlPlane.Api/Endpoints/GiteaWebhookEndpoints.cs
2026-04-26 16:12:00 -04:00

160 lines
6.0 KiB
C#

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<string, string> 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<IResult> HandlePushAsync(
HttpRequest request,
IPublishEndpoint bus,
IConfiguration config,
ILogger<GiteaWebhookEndpointsMarker> 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<GiteaPushPayload>(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<GiteaPushPayload>(
request.Body, JsonOpts, ct);
return await ProcessPayloadAsync(payload, bus, logger, ct);
}
}
private static async Task<IResult> ProcessPayloadAsync(
GiteaPushPayload? payload,
IPublishEndpoint bus,
ILogger<GiteaWebhookEndpointsMarker> 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<byte[]> 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;
}