2026-09-01 16:37:53 +00:00
using System.Text.Json ;
using Microsoft.AspNetCore.Mvc ;
using Microsoft.EntityFrameworkCore ;
using w4c_workflows.Data ;
using w4c_workflows.Models ;
2026-09-11 22:02:46 +00:00
using w4c_workflows.Services.Quota ;
2026-09-01 16:37:53 +00:00
using w4c_workflows.Services.Runs ;
using w4c_workflows.Services.Triggers ;
namespace w4c_workflows.Controllers ;
/// <summary>
/// Webhook receiver for workflows with <c>trigger.type = webhook</c>. The route
/// is PUBLIC (external callers have no operator key); access is governed by an
/// optional shared secret (<c>Workflows:WebhookSecret</c>) verified against the
/// <c>X-Webhook-Secret</c> header. When no secret is configured the endpoint is
/// open — acceptable for v1 dev, hardened in step 13.
/// </summary>
[ApiController]
[Route("/h")]
public class WebhooksController : ControllerBase
{
private readonly WorkflowsDbContext _db ;
private readonly IRunLauncher _launcher ;
private readonly ILogger < WebhooksController > _logger ;
private readonly string? _sharedSecret ;
public WebhooksController (
WorkflowsDbContext db ,
IRunLauncher launcher ,
IConfiguration config ,
ILogger < WebhooksController > logger )
{
_db = db ;
_launcher = launcher ;
_logger = logger ;
_sharedSecret = string . IsNullOrWhiteSpace ( config [ "Workflows:WebhookSecret" ] )
? null
: config [ "Workflows:WebhookSecret" ] ;
}
/// <summary>Receives a webhook at <c>/h/{path}</c> and fires every matching workflow.</summary>
[HttpPost("{**path}")]
public async Task < IActionResult > 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 < Guid > ( matches . Count ) ;
2026-09-11 22:02:46 +00:00
WorkflowQuotaExceededException ? lastQuotaError = null ;
2026-09-01 16:37:53 +00:00
foreach ( var workflow in matches )
{
2026-09-11 22:02:46 +00:00
try
{
runIds . Add ( await _launcher . LaunchAsync (
new LaunchRequest ( workflow . TenantId , workflow . Id , workflow . TriggerJson , input , $"webhook:{correlation}" ) , ct ) ) ;
}
catch ( WorkflowQuotaExceededException ex )
{
// One tenant may be over quota while another is not; skip only the
// exhausted one so the webhook still fires for everybody else.
lastQuotaError = ex ;
_logger . LogWarning (
"Webhook {Path}: execution quota exceeded for workflow {WorkflowId} (tenant {TenantId})" ,
webhookPath , workflow . Id , workflow . TenantId ) ;
}
2026-09-01 16:37:53 +00:00
}
2026-09-11 22:02:46 +00:00
// Nothing could be started and the only reason was the quota → surface 429
// so the caller can back off instead of believing the webhook was handled.
if ( runIds . Count = = 0 & & lastQuotaError ! = null )
return StatusCode ( StatusCodes . Status429TooManyRequests , new { error = lastQuotaError . Message , quota = lastQuotaError . Quota } ) ;
2026-09-01 16:37:53 +00:00
_logger . LogInformation ( "Webhook {Path} fired {Count} workflow(s) for tenant(s) {Tenants}" ,
2026-09-11 22:02:46 +00:00
webhookPath , runIds . Count , string . Join ( "," , matches . Select ( m = > m . TenantId ) . Distinct ( ) ) ) ;
2026-09-01 16:37:53 +00:00
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 < List < Workflow > > 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 < Workflow > ( ) ;
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 < string? > 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 ;
}
}