using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Polly.Bulkhead; using ZymonicGateway.Contracts; using ZymonicServices; namespace ZymonicAppLogUploadGatewayPlugin; public sealed class ZymonicAppLogUploadGatewayPlugin : IZymonicGatewayPlugin { public string Name => "ZymonicAppLogUpload"; public IEnumerable Routes => [new ZymonicAppLogUploadGatewayRoute()]; public IEnumerable QueueHandlers => [new ZymonicAppLogUploadGatewayQueueHandler()]; } public sealed class ZymonicAppLogUploadGatewayRoute : IZymonicGatewayRoute { public string Name => "AppLogUpload"; public string RoutePattern => "/app/logs/upload"; public string[] Methods => ["POST"]; public async Task HandleAsync(ZymonicGatewayPluginRequest request, CancellationToken ct) { var configuredApiKey = GetConfig(request, "ApiKey"); if (!string.IsNullOrWhiteSpace(configuredApiKey) && !HasValidApiKey(request, configuredApiKey)) { return new ZymonicGatewayPluginResponse { StatusCode = 401, Body = new { error = "Invalid app log upload API key." } }; } var queueStore = request.Services?.GetService(typeof(IZymonicGatewayQueueStore)) as IZymonicGatewayQueueStore; if (queueStore is null) { return new ZymonicGatewayPluginResponse { StatusCode = 503, Body = new { error = "Gateway queue store is not available." } }; } ZymonicAppLogUploadAutoprocess process; try { process = ZymonicAppLogUploadMapper.MapToProcess(request.Body ?? "", GetConfig(request, "EffectiveUser")); } catch (JsonException ex) { return new ZymonicGatewayPluginResponse { StatusCode = 400, Body = new { error = "App log upload JSON payload could not be decoded.", detail = ex.Message } }; } catch (ArgumentException ex) { return new ZymonicGatewayPluginResponse { StatusCode = 400, Body = new { error = ex.Message } }; } var queuedRequest = new ZymonicGatewayQueuedRequest { Source = "zymonic_app", Route = request.Path, EffectiveUser = GetConfig(request, "EffectiveUser") ?? "app_log_upload", AuthMethod = string.IsNullOrWhiteSpace(configuredApiKey) ? "none" : "api_key", RequestPayloadJson = ZymonicAppLogUploadMapper.SerializeProcess(process), RequestMetadataJson = ZymonicAppLogUploadMapper.BuildMetadataJson(request.Body ?? "") }; var requestId = await queueStore.QueueRequestAsync(queuedRequest, ct); return new ZymonicGatewayPluginResponse { StatusCode = 202, Body = new { requestId, status = ZymonicGatewayQueueStatus.Queued } }; } private static bool HasValidApiKey(ZymonicGatewayPluginRequest request, string configuredApiKey) { return TryGetHeader(request.Headers, "X-Zymonic-Gateway-Key", out var suppliedApiKey) && string.Equals(suppliedApiKey, configuredApiKey, StringComparison.Ordinal); } private static bool TryGetHeader(IReadOnlyDictionary headers, string name, out string? value) { value = headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : headers.FirstOrDefault(header => string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)).Value?.FirstOrDefault(); return !string.IsNullOrWhiteSpace(value); } private static string? GetConfig(ZymonicGatewayPluginRequest request, string key) { return request.Configuration.TryGetValue(key, out var value) ? value : null; } } public sealed class ZymonicAppLogUploadGatewayQueueHandler : IZymonicGatewayQueueHandler { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString, Converters = { new NumberOrStringToStringConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter() } }; public string Name => "ZymonicAppLogUpload"; public bool CanHandle(ZymonicGatewayQueuedWork request) { return string.Equals(request.Source, "zymonic_app", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Route, "/app/logs/upload", StringComparison.OrdinalIgnoreCase); } public async Task HandleAsync(ZymonicGatewayQueuedWork request, CancellationToken ct) { var services = request.Services ?? throw new InvalidOperationException("Gateway services are not available to the queue handler."); var process = JsonSerializer.Deserialize(request.RequestPayloadJson ?? "", JsonOptions) ?? throw new InvalidOperationException("Queued app log upload payload could not be decoded."); if (process.ProcessForm is null) { throw new InvalidOperationException("Queued app log upload payload did not contain a process form."); } var wrapper = new ZymonicAppLogUploadAutoProcessWrapper( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); await wrapper.DoTransition( process.Transition ?? "zz_app_log_upload_autoprocess_save_transition", process.ProcessForm, response => IsTrue(response.ProcessResponse?.zz_app_log_upload_autoprocess_save_transition?.success), debugMode: request.DebugMode, requireAuthenticate: false, ct: ct, processId: process.process_id, retryCount: request.IsManualRetry ? 0 : 10); } private static bool IsTrue(string? value) { return string.Equals(value, "Y", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase); } } public static class ZymonicAppLogUploadMapper { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; public static ZymonicAppLogUploadAutoprocess MapToProcess(string rawBody, string? defaultUsername) { var upload = JsonSerializer.Deserialize(rawBody, JsonOptions) ?? throw new ArgumentException("App log upload payload is empty."); var fileName = string.IsNullOrWhiteSpace(upload.FileName) ? $"zymonic-app-log-{DateTime.UtcNow:yyyyMMddHHmmss}.log" : upload.FileName; var contentType = string.IsNullOrWhiteSpace(upload.ContentType) ? "text/plain" : upload.ContentType; var base64 = ResolveBase64Content(upload); return new ZymonicAppLogUploadAutoprocess { Transition = "zz_app_log_upload_autoprocess_save_transition", ProcessForm = new ZymonicAppLogUploadAutoform { record = "ZZNEW", zz_app_log_upload_username = string.IsNullOrWhiteSpace(upload.Username) ? defaultUsername : upload.Username, zz_app_log_upload_file = fileName, zz_app_log_upload_file_file_name = fileName, zz_app_log_upload_file_file_type = contentType, zz_app_log_upload_file_base64 = base64, zz_app_log_upload_comments = upload.Comments } }; } public static string SerializeProcess(ZymonicAppLogUploadAutoprocess process) { return JsonSerializer.Serialize(process, JsonOptions); } public static string BuildMetadataJson(string rawBody) { var metadata = new { source = "zymonic_app", contentLength = rawBody.Length, queuedAtUtc = DateTime.UtcNow }; return JsonSerializer.Serialize(metadata, JsonOptions); } private static string ResolveBase64Content(ZymonicAppLogUploadRequest upload) { if (!string.IsNullOrWhiteSpace(upload.Base64)) { try { Convert.FromBase64String(upload.Base64); return upload.Base64; } catch (FormatException ex) { throw new ArgumentException("App log upload 'base64' value is not valid base64.", ex); } } if (!string.IsNullOrEmpty(upload.Text)) { return Convert.ToBase64String(Encoding.UTF8.GetBytes(upload.Text)); } throw new ArgumentException("App log upload payload must include either 'base64' or 'text'."); } } public sealed class ZymonicAppLogUploadRequest { public string? FileName { get; set; } public string? ContentType { get; set; } public string? Base64 { get; set; } public string? Text { get; set; } public string? Comments { get; set; } public string? Username { get; set; } }