160 lines
6.0 KiB
C#
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;
|
|
}
|