using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; namespace EonaCat.DoxaApi.Middleware { // This file is part of the EonaCat project(s) which is released under the Apache License. // See the LICENSE file or go to https://EonaCat.com/license for full license details. /// /// Backs the DoxaApi "Console" feature: lets the UI send an arbitrary HTTP request /// (method, absolute URL, headers, body) through the server rather than the browser. /// This sidesteps CORS restrictions when documenting third-party or differently-hosted /// APIs, and keeps credentials out of client-side network logs when desired. /// /// Only absolute http(s) URLs are allowed, and a small set of hop-by-hop / sensitive /// headers are stripped from both the outgoing request and the proxied response to /// avoid surprising behavior. /// internal static class ConsoleProxy { private static readonly HttpClient _client = new(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) }; private static readonly HashSet _blockedRequestHeaders = new(StringComparer.OrdinalIgnoreCase) { "host", "content-length", "connection", "transfer-encoding" }; public static async Task HandleAsync(HttpContext context, JsonSerializerOptions writeOptions) { ConsoleRequest? req; try { req = await JsonSerializer.DeserializeAsync(context.Request.Body, ReadOptions); } catch (Exception ex) { context.Response.StatusCode = 400; await context.Response.WriteAsync($"Invalid console request body: {ex.Message}"); return; } if (req is null || string.IsNullOrWhiteSpace(req.Url)) { context.Response.StatusCode = 400; await context.Response.WriteAsync("A 'url' field is required."); return; } if (!Uri.TryCreate(req.Url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { context.Response.StatusCode = 400; await context.Response.WriteAsync("Only absolute http:// or https:// URLs are supported."); return; } var method = string.IsNullOrWhiteSpace(req.Method) ? "GET" : req.Method.ToUpperInvariant(); using var requestMessage = new HttpRequestMessage(new HttpMethod(method), uri); if (req.Headers is not null) { foreach (var (key, value) in req.Headers) { if (_blockedRequestHeaders.Contains(key)) { continue; } requestMessage.Headers.TryAddWithoutValidation(key, value); } } if (!string.IsNullOrEmpty(req.Body) && method is not ("GET" or "HEAD")) { var contentType = req.Headers?.FirstOrDefault(h => string.Equals(h.Key, "content-type", StringComparison.OrdinalIgnoreCase)).Value ?? "application/json"; requestMessage.Content = new StringContent(req.Body, Encoding.UTF8); requestMessage.Content.Headers.Remove("Content-Type"); requestMessage.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); } var sw = System.Diagnostics.Stopwatch.StartNew(); try { using var responseMessage = await _client.SendAsync(requestMessage, context.RequestAborted); sw.Stop(); var responseBody = await responseMessage.Content.ReadAsStringAsync(context.RequestAborted); var responseHeaders = new Dictionary(); foreach (var h in responseMessage.Headers) { responseHeaders[h.Key] = string.Join(", ", h.Value); } foreach (var h in responseMessage.Content.Headers) { responseHeaders[h.Key] = string.Join(", ", h.Value); } var result = new ConsoleResponse { StatusCode = (int)responseMessage.StatusCode, ElapsedMs = sw.ElapsedMilliseconds, Headers = responseHeaders, Body = responseBody }; context.Response.ContentType = "application/json; charset=utf-8"; await JsonSerializer.SerializeAsync(context.Response.Body, result, writeOptions); } catch (TaskCanceledException) { context.Response.StatusCode = 504; await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse { StatusCode = 0, ElapsedMs = sw.ElapsedMilliseconds, NetworkError = true, Body = "Request timed out after 30 seconds." }, writeOptions); } catch (Exception ex) { context.Response.StatusCode = 502; await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse { StatusCode = 0, ElapsedMs = sw.ElapsedMilliseconds, NetworkError = true, Body = ex.Message }, writeOptions); } } private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; } }