using System.Text;
using Microsoft.AspNetCore.Http;
using EonaCat.Json;
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"
};
private static readonly JsonSerializerSettings _settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
public static async Task HandleAsync(HttpContext context, JsonSerializerSettings writeOptions)
{
ConsoleRequest? req;
try
{
using var reader = new StreamReader(context.Request.Body);
var json = await reader.ReadToEndAsync();
req = JsonHelper.ToObject(json);
}
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";
var json = JsonHelper.ToJson(result, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}
catch (TaskCanceledException)
{
context.Response.StatusCode = 504;
var errorResponse = new ConsoleResponse
{
StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true,
Body = "Request timed out after 30 seconds."
};
var json = JsonHelper.ToJson(errorResponse, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}
catch (Exception ex)
{
context.Response.StatusCode = 502;
var errorResponse = new ConsoleResponse
{
StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true,
Body = ex.Message
};
var json = JsonHelper.ToJson(errorResponse, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}
}
}
}