using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using PuppeteerSharp;
namespace AyaNova.Util
{
///
/// Where the report page's API calls are sent when AyaNova runs inside IIS: the address and port a user's request
/// arrived on, with the host name and scheme of that request
///
internal sealed class ReportApiTarget
{
public string Scheme { get; set; }
public string Host { get; set; }//host header value, with the port when the request had one
public string Address { get; set; }
public int Port { get; set; }
internal string BaseUrl => $"{Scheme}://{Host}/api/{AyaNovaVersion.CurrentApiVersion}/";
public override string ToString() => $"{Scheme}://{Host} via {Address}:{Port}";
}
///
/// case 4632: inside IIS (in-process hosting) AyaNova has no port of its own: nothing listens on AYANOVA_USE_URLS, and
/// the port of the user's request is IIS's, often https-only and bound to a host name. So the report page could not
/// reach the API at http://127.0.0.1:port (user reports, or notification reports at the AYANOVA_USE_URLS port), and the
/// override setting only helped where the server can reach itself by its public name. Under IIS the report page's
/// requests to the API URL are intercepted and made here instead, to the local address and port a user's request
/// arrived on, with that request's host name and scheme: the IIS binding and certificate match, and no DNS or router
/// is involved. Outside IIS the request goes on from the browser as before.
/// case 4719: in every kind of install the page holds only placeholders for the renderer's token and download key (a
/// template's script could send the real ones anywhere); the real values are put in here, only on requests to the API URL
///
internal static class ReportApiRelay
{
//path the page is first opened at, so it has the API's origin and its API calls are same-origin (no CORS preflight,
//which the browser would send itself, past the interception)
private const string PAGE_PATH = "ay-report-render-page";
//case 4719: what the page sees instead of the renderer's token and download key
internal const string TOKEN_PLACEHOLDER = "ay-report-render-token";
internal const string DOWNLOAD_KEY_PLACEHOLDER = "ay-report-render-download-key";
private static volatile ReportApiTarget _lastSeen;
///
/// The address, port, host name and scheme this request arrived with, for relaying a report started by it
///
internal static ReportApiTarget FromRequest(HttpContext context)
{
var address = context.Connection.LocalIpAddress;
if (address == null)
return null;
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
//the IIS server variable says if this connection is TLS; Request.Scheme may come from a proxy's X-Forwarded-Proto
var https = string.Equals(context.GetServerVariable("HTTPS"), "on", StringComparison.OrdinalIgnoreCase);
return new ReportApiTarget
{
Scheme = https ? "https" : "http",
Host = context.Request.Host.HasValue ? context.Request.Host.Value : address.ToString(),
Address = address.ToString(),
Port = context.Connection.LocalPort
};
}
///
/// Keeps the target of the latest signed-in request, for reports rendered without one (customer notifications)
///
internal static void Remember(HttpContext context)
{
if (!ServerBootConfig.HOSTED_IN_IIS)
return;
var target = FromRequest(context);
var last = _lastSeen;
if (target != null && (last == null || last.ToString() != target.ToString()))
_lastSeen = target;
}
///
/// Target for a report rendered without a user request: the latest signed-in request, else the override URL
/// sent to this machine; null outside IIS or when neither is known
///
internal static ReportApiTarget ForBackgroundRender()
{
if (!ServerBootConfig.HOSTED_IN_IIS)
return null;
if (_lastSeen != null)
return _lastSeen;
if (Uri.TryCreate(ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE?.Trim(), UriKind.Absolute, out var o))
return new ReportApiTarget { Scheme = o.Scheme, Host = o.Authority, Address = IPAddress.Loopback.ToString(), Port = o.Port };
return null;
}
///
/// Opens the page at the API URL's origin and handles the page's requests under the API URL: puts the real token
/// and download key in place of the placeholders, then relays the request to the target (inside IIS) or lets the
/// browser make it (target null). Returns the relay's HTTP client to dispose when the page is done (null without a target)
///
internal static async Task AttachAsync(IPage page, string apiUrl, ReportApiTarget target, string authorization, string downloadKey, TimeSpan timeout, Action pageLog, ILogger log)
{
HttpClient client = null;
if (target != null)
{
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseCookies = false,
UseProxy = false,
//always the local address, whatever the URL's host name resolves to; TLS still checks the certificate for that name
ConnectCallback = async (context, cancel) =>
{
var address = IPAddress.Parse(target.Address);
var socket = new Socket(address.AddressFamily, SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(address, target.Port), cancel);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
};
client = new HttpClient(handler) { Timeout = timeout };
}
var pageUrl = apiUrl + PAGE_PATH;
var where = target == null ? "browser" : target.ToString();
await page.SetRequestInterceptionAsync(true);
page.Request += async (sender, e) =>
{
var request = e.Request;
try
{
if (request.Url == pageUrl)
{
await request.RespondAsync(new ResponseData { Status = HttpStatusCode.OK, ContentType = "text/html", Body = "" });
return;
}
//anywhere else as it is, without credentials (a template may load its own images, fonts or scripts)
if (!request.Url.StartsWith(apiUrl, StringComparison.OrdinalIgnoreCase))
{
await request.ContinueAsync();
return;
}
//case 4719: the real token and download key, only here
var url = downloadKey == null ? request.Url : request.Url.Replace("t=" + DOWNLOAD_KEY_PLACEHOLDER, "t=" + downloadKey);
var requestHeaders = new Dictionary(request.Headers, StringComparer.OrdinalIgnoreCase);
if (authorization != null && requestHeaders.TryGetValue("Authorization", out var auth) && auth.Contains(TOKEN_PLACEHOLDER))
requestHeaders["Authorization"] = authorization;
if (client == null)
{
await request.ContinueAsync(new Payload { Url = url, Headers = requestHeaders });
return;
}
using var message = new HttpRequestMessage(request.Method, target.BaseUrl + url.Substring(apiUrl.Length));
if (request.HasPostData)
message.Content = new StringContent(request.PostData ?? await request.FetchPostDataAsync() ?? string.Empty);
foreach (var header in requestHeaders)
{
if (header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
continue;
if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value) && message.Content != null)
{
message.Content.Headers.Remove(header.Key);
message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
using var response = await client.SendAsync(message);
var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var header in response.Headers.Concat(response.Content.Headers))
{
if (header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
continue;
headers[header.Key] = string.Join(", ", header.Value);
}
await request.RespondAsync(new ResponseData { Status = response.StatusCode, Headers = headers, BodyData = await response.Content.ReadAsByteArrayAsync() });
}
catch (Exception ex)
{
var reason = ex.InnerException != null ? $"{ex.Message} ({ex.InnerException.Message})" : ex.Message;
pageLog($"ERROR: AyaNova API request {request.Method} {request.Url} via {where} failed: {reason}");
log.LogWarning($"Report API request {request.Method} {request.Url} via {where} failed: {reason}");
try { await request.AbortAsync(); } catch { }
}
};
await page.GoToAsync(pageUrl);
return client;
}
}
}