shoc-backend/Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs

115 lines
4.4 KiB
C#
Raw Permalink Normal View History

using System.Buffers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[AllowAnonymous]
[Route("api/webhooks/work-orders")]
public sealed class WorkOrderWebhookController : ControllerBase
{
private readonly IWorkOrderWebhookService _service;
public WorkOrderWebhookController(IWorkOrderWebhookService service)
{
_service = service;
}
[HttpPost]
[Consumes("application/json")]
public async Task<IActionResult> Receive(CancellationToken cancellationToken)
{
if (!HasSupportedContentType(Request.ContentType))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
if (Request.Headers.ContentEncoding.Count > 0
&& !string.Equals(
Request.Headers.ContentEncoding.ToString(),
"identity",
StringComparison.OrdinalIgnoreCase))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
var maxBytes = _service.MaximumBodyBytes;
if (Request.ContentLength > maxBytes)
return StatusCode(StatusCodes.Status413PayloadTooLarge);
var body = await ReadBoundedBodyAsync(Request.Body, maxBytes, cancellationToken);
if (body == null)
return StatusCode(StatusCodes.Status413PayloadTooLarge);
var result = await _service.ProcessAsync(
new WorkOrderWebhookRequest(
SingleHeader("X-SH-Timestamp"),
SingleHeader("X-SH-Key-Id"),
SingleHeader("X-SH-Signature"),
body),
cancellationToken);
return result.Status switch
{
WorkOrderWebhookStatus.Applied => Ok(new
{
status = "accepted",
state_mutation_skipped = result.StateMutationSkipped
}),
WorkOrderWebhookStatus.Duplicate => Ok(new { status = "duplicate" }),
WorkOrderWebhookStatus.Unauthorized => Unauthorized(new { error = "unauthorized" }),
WorkOrderWebhookStatus.Disabled => StatusCode(
StatusCodes.Status503ServiceUnavailable,
new { error = "webhook unavailable" }),
WorkOrderWebhookStatus.InvalidEnvelope => BadRequest(new { error = "invalid envelope" }),
WorkOrderWebhookStatus.HashConflict => Conflict(new { error = "delivery conflict" }),
WorkOrderWebhookStatus.Unavailable => StatusCode(
StatusCodes.Status503ServiceUnavailable,
new { error = "webhook unavailable" }),
_ => StatusCode(StatusCodes.Status503ServiceUnavailable)
};
}
private string? SingleHeader(string name)
{
var values = Request.Headers[name];
return values.Count == 1 ? values[0] : null;
}
private static bool HasSupportedContentType(string? contentType)
{
return MediaTypeHeaderValue.TryParse(contentType, out var parsed)
&& string.Equals(
parsed.MediaType.Value,
"application/json",
StringComparison.OrdinalIgnoreCase);
}
private static async Task<byte[]?> ReadBoundedBodyAsync(
Stream source,
int maxBytes,
CancellationToken cancellationToken)
{
var rented = ArrayPool<byte>.Shared.Rent(81920);
try
{
using var destination = new MemoryStream(Math.Min(maxBytes, 81920));
while (true)
{
var read = await source.ReadAsync(rented.AsMemory(0, rented.Length), cancellationToken);
if (read == 0)
return destination.ToArray();
if (destination.Length + read > maxBytes)
return null;
await destination.WriteAsync(rented.AsMemory(0, read), cancellationToken);
}
}
finally
{
ArrayPool<byte>.Shared.Return(rented, clearArray: true);
}
}
}
}