using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
using w4c_workflows.Services.Runs;
using w4c_workflows.Services.Triggers;
namespace w4c_workflows.Controllers;
///
/// Webhook receiver for workflows with trigger.type = webhook. The route
/// is PUBLIC (external callers have no operator key); access is governed by an
/// optional shared secret (Workflows:WebhookSecret) verified against the
/// X-Webhook-Secret header. When no secret is configured the endpoint is
/// open — acceptable for v1 dev, hardened in step 13.
///
[ApiController]
[Route("/h")]
public class WebhooksController : ControllerBase
{
private readonly WorkflowsDbContext _db;
private readonly IRunLauncher _launcher;
private readonly ILogger _logger;
private readonly string? _sharedSecret;
public WebhooksController(
WorkflowsDbContext db,
IRunLauncher launcher,
IConfiguration config,
ILogger logger)
{
_db = db;
_launcher = launcher;
_logger = logger;
_sharedSecret = string.IsNullOrWhiteSpace(config["Workflows:WebhookSecret"])
? null
: config["Workflows:WebhookSecret"];
}
/// Receives a webhook at /h/{path} and fires every matching workflow.
[HttpPost("{**path}")]
public async Task Receive(string? path, CancellationToken ct)
{
var webhookPath = "/h/" + (path ?? string.Empty);
if (!Authorized())
return Unauthorized(new { error = "Invalid webhook secret." });
var matches = await FindMatchesAsync(webhookPath, ct);
if (matches.Count == 0)
return NotFound(new { error = $"No webhook workflow registered for path '{webhookPath}'." });
var input = await ReadInputAsync(ct);
var correlation = Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
var runIds = new List(matches.Count);
foreach (var workflow in matches)
{
runIds.Add(await _launcher.LaunchAsync(
new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, input, $"webhook:{correlation}"), ct));
}
_logger.LogInformation("Webhook {Path} fired {Count} workflow(s) for tenant(s) {Tenants}",
webhookPath, matches.Count, string.Join(",", matches.Select(m => m.TenantId).Distinct()));
return Accepted(new { runs = runIds });
}
private bool Authorized()
{
// When no secret is configured, reject all webhook requests. An empty
// secret previously meant "open" — acceptable for dev but a security
// hole in production. Callers must set Workflows:WebhookSecret. Log a
// warning so this fail-closed state is not silent.
if (_sharedSecret == null)
{
_logger.LogWarning("Workflows:WebhookSecret is not configured; the webhook endpoint is disabled and every request to /h/* will be rejected with 401.");
return false;
}
var provided = Request.Headers["X-Webhook-Secret"].FirstOrDefault();
return provided != null && CryptographicOperationsEquals(provided, _sharedSecret);
}
private async Task> FindMatchesAsync(string webhookPath, CancellationToken ct)
{
var candidates = await _db.Workflows
.Where(w => w.Status == WorkflowStatus.Compiled && w.TriggerJson != null)
.ToListAsync(ct);
var matches = new List();
foreach (var workflow in candidates)
{
var spec = TriggerSpec.Parse(workflow.TriggerJson, out _);
if (spec?.Type == TriggerType.Webhook
&& string.Equals(spec.WebhookPath, webhookPath, StringComparison.Ordinal))
matches.Add(workflow);
}
return matches;
}
private async Task ReadInputAsync(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body);
var body = (await reader.ReadToEndAsync(ct)).Trim();
if (body.Length == 0)
return null;
try
{
using var _ = JsonDocument.Parse(body);
return body; // already valid JSON — pass through verbatim
}
catch (JsonException)
{
return JsonSerializer.Serialize(body); // wrap non-JSON bodies as a JSON string
}
}
private static bool CryptographicOperationsEquals(string a, string b)
{
var aBytes = System.Text.Encoding.UTF8.GetBytes(a);
var bBytes = System.Text.Encoding.UTF8.GetBytes(b);
if (aBytes.Length != bBytes.Length)
return false;
var result = 0;
for (var i = 0; i < aBytes.Length; i++)
result |= aBytes[i] ^ bBytes[i];
return result == 0;
}
}