using System; using System.Net; using System.Threading; using System.Threading.Tasks; using FastEndpoints; using GB5Shared.DirectAction; using GB5Shared.DTO.DirectAction; using FrameworkBLL.DirectAction; using FrameworkDAL.CustomCode.DirectAction; using FrameworkDAL.DTO.DirectAction; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; namespace FrameworkSL.Endpoints.Action { /// /// POST /Action/Submit (application/x-www-form-urlencoded) /// /// Handles form submission for DirectAction buttons where NEEDSINPUT=1. /// Receives the signed token (from the hidden field) and user-provided remarks, /// validates the token, records it as used (with remarks), then calls the /// configured API endpoint with remarks substituted into the payload template. /// public class ActionSubmitEndpoint : Endpoint { private readonly IDirectActionTokenService _tokenService; private readonly IDirectActionTokenDAL _tokenDAL; private readonly IGenericApiDirectActionHandler _handler; private readonly ILogger _logger; public ActionSubmitEndpoint( IDirectActionTokenService tokenService, IDirectActionTokenDAL tokenDAL, IGenericApiDirectActionHandler handler, ILogger logger) { _tokenService = tokenService; _tokenDAL = tokenDAL; _handler = handler; _logger = logger; } public override void Configure() { Post("/Action/Submit"); AllowAnonymous(); Description(b => b.WithTags("DirectAction")); } public override async Task HandleAsync(ActionSubmitRequest req, CancellationToken ct) { _logger.LogInformation("ActionSubmit hit — token present: {HasToken}", !string.IsNullOrWhiteSpace(req.Token)); try { if (string.IsNullOrWhiteSpace(req.Token)) { await SendHtmlAsync(HtmlPage("Invalid Request", "No token provided.", false), ct); return; } // ── Validate token (signature + expiry) ─────────────────────────── if (!_tokenService.TryValidate(req.Token, out var actionCode, out var contextId, out var tenantId, out var assigneeUserId, out var databaseName, out var tokenExpiresAt)) { await SendHtmlAsync(HtmlPage("Link Expired or Invalid", "This action link has expired or is not valid.", false), ct); return; } var login = new LoginDTO { ClientId = tenantId, UserId = assigneeUserId, DatabaseName = databaseName, ConnectionDatabaseName = databaseName }; // ── One-time use check ──────────────────────────────────────────── var tokenHash = ComputeHash(req.Token); var existing = await _tokenDAL.GetByHashAsync(tokenHash, login, ct); if (existing != null) { var msg = existing.UsedAt.HasValue ? $"This link was already used on {existing.UsedAt:yyyy-MM-dd HH:mm} UTC." : "This link has been revoked."; await SendHtmlAsync(HtmlPage("Already Actioned", msg, false), ct); return; } var remarks = req.Remarks?.Trim(); // ── Record token use with remarks ───────────────────────────────── await _tokenDAL.InsertUsedAsync(new DirectActionTokenDTO { TokenHash = tokenHash, ContextId = contextId, ActionCode = actionCode, AssigneeUserId = assigneeUserId, TenantId = tenantId, ExpiresAt = tokenExpiresAt, UsedAt = DateTime.UtcNow, Status = 1, Remarks = remarks }, login, ct); // ── Execute action with remarks ─────────────────────────────────── var frameworkBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var result = await _handler.ExecuteAsync(actionCode, contextId, assigneeUserId, remarks, login, frameworkBaseUrl, ct); if (!result.Success) { await SendHtmlAsync(HtmlPage("Error", $"The action could not be completed: {WebUtility.HtmlEncode(result.ErrorMessage ?? "Unknown error")}", false), ct); return; } await SendHtmlAsync(HtmlPage("Action Submitted", "Your response has been recorded successfully.", true), ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogInformation("ActionSubmitEndpoint: request cancelled by client"); if (!HttpContext.Response.HasStarted) HttpContext.Response.StatusCode = 499; } catch (Exception ex) { _logger.LogError(ex, "ActionSubmitEndpoint: unhandled error"); if (!HttpContext.Response.HasStarted) await SendHtmlAsync(HtmlPage("System Error", "An unexpected error occurred. Please contact your administrator.", false), ct); } } private async Task SendHtmlAsync(string html, CancellationToken ct) { HttpContext.Response.ContentType = "text/html; charset=utf-8"; await HttpContext.Response.WriteAsync(html, ct); } private static string ComputeHash(string token) { using var sha = System.Security.Cryptography.SHA256.Create(); var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token)); return Convert.ToHexString(bytes).ToLowerInvariant(); } private static string HtmlPage(string title, string message, bool success) { var color = success ? "#2e7d32" : "#c62828"; var icon = success ? "✓" : "✗"; return $@" {WebUtility.HtmlEncode(title)}

{icon} {WebUtility.HtmlEncode(title)}

{WebUtility.HtmlEncode(message)}

GoodBooks ERP — Action Confirmation

"; } } public record ActionSubmitRequest(string? Token, string? Remarks); }