|
using Microsoft.AspNetCore.Mvc.ModelBinding; |
|
using Microsoft.Net.Http.Headers; |
|
using System.Dynamic; |
|
using System.Net; |
|
using System.Net.Sockets; |
|
using System.Text.Json; |
|
using System.Text.Json.Serialization; |
|
using Headers = System.Net.Http.Headers; |
|
|
|
namespace yourProject.Services; |
|
|
|
public static class ClassicASP { |
|
|
|
/// <summary> |
|
/// Adds scoped service ClassicASP.Session to make the classic ASP session variables available. |
|
/// </summary> |
|
public static IServiceCollection AddClassicASP( this IServiceCollection services ) { |
|
return services.AddScoped( sp => { |
|
var _httpAccessor = sp.GetRequiredService<IHttpContextAccessor>(); |
|
return Session.Fetch( _httpAccessor.HttpContext! ); |
|
} ); |
|
} |
|
|
|
/// <summary> |
|
/// Each of these match corresponding HTTP methods that I added to the classic |
|
/// ASP ISAPI handler in web.config to obfuscate manipulating the session. |
|
/// </summary> |
|
public enum PushMode { Merge, Clear, Abandon } |
|
|
|
/// <summary> |
|
/// Reusable JSON options, created once for lifetime of app for efficiency. |
|
/// </summary> |
|
private static JsonSerializerOptions? _jsonOptions; |
|
|
|
/// <summary> |
|
/// Accessor for reusable JSON options with auto-load on first use. |
|
/// </summary> |
|
public static JsonSerializerOptions JsonOptions { |
|
//REF: good resource for System.Text.Json vs. Newtonsoft.Json: |
|
//https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft?pivots=dotnet-8-0 |
|
get { |
|
if (_jsonOptions is null) { |
|
_jsonOptions = new JsonSerializerOptions { |
|
PropertyNameCaseInsensitive = true, |
|
NumberHandling = JsonNumberHandling.AllowReadingFromString, |
|
PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, |
|
AllowTrailingCommas = true, |
|
ReadCommentHandling = JsonCommentHandling.Skip, |
|
}; |
|
_jsonOptions.Converters.Add( new ObjectToInferredTypesConverter() ); |
|
} |
|
return _jsonOptions; |
|
} |
|
} |
|
|
|
|
|
/// <summary> |
|
/// Puts actual value in object type rather than JsonElement wrapper. |
|
/// Useful for Dictionary<string, object> or direct object properties. |
|
/// Just another workaround for System.Text.Json missing features we got used to with Newtonsoft.Json. |
|
/// </summary> |
|
private class ObjectToInferredTypesConverter : JsonConverter<object> { |
|
public override object Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) |
|
=> reader.TokenType switch { |
|
JsonTokenType.True => true, |
|
JsonTokenType.False => false, |
|
JsonTokenType.Number when reader.TryGetInt64( out long l ) => l, |
|
JsonTokenType.Number => reader.GetDouble(), |
|
JsonTokenType.String when reader.TryGetDateTime( out DateTime datetime ) => datetime, |
|
JsonTokenType.String => reader.GetString()!, |
|
JsonTokenType.StartArray => JsonSerializer.Deserialize<object[]>( ref reader, options )!, |
|
_ => JsonDocument.ParseValue( ref reader ).RootElement.Clone() |
|
}; |
|
|
|
public override void Write( Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options ) |
|
=> JsonSerializer.Serialize( writer, objectToWrite, objectToWrite.GetType(), options ); |
|
} |
|
public class Session { |
|
|
|
/// <summary> |
|
/// Id of the classic ASP session. |
|
/// </summary> |
|
[JsonInclude] |
|
public ulong? SessionId { get; private set; } |
|
|
|
/// <summary> |
|
/// Internal container of ASP session variables. |
|
/// </summary> |
|
[JsonInclude] |
|
private ExpandoObject SessionVars { get; set; } = new(); |
|
|
|
/// <summary> |
|
/// Use this dynamic ReadOnlyDictionary to enumerate session variables. |
|
/// If a session variable is an array you can enumerate it without casting due to parent accessor being dynamic. |
|
/// If you need to modify a value, use the indexer on Session. |
|
/// </summary> |
|
public dynamic AsEnumerable() => SessionVars.AsReadOnly(); |
|
|
|
/// <summary> |
|
/// Case-insensitive check if session variable exists. |
|
/// </summary> |
|
public bool Exists( string key ) => SessionVars.Any( kv => kv.Key.Equals( key, StringComparison.InvariantCultureIgnoreCase ) ); |
|
|
|
/// <summary> |
|
/// Case-insensitive get value with optional default if null or missing. |
|
/// </summary> |
|
public object? this[string key, object? defaultValue] => this[key] ?? defaultValue; |
|
|
|
/// <summary> |
|
/// Case-insensitive get/set value. Returns null if missing or set null. |
|
/// </summary> |
|
public object? this[string key] { |
|
get => SessionVars |
|
.Where( kv => kv.Key.Equals( key, StringComparison.InvariantCultureIgnoreCase ) ) |
|
.Select( kv => kv.Value ) |
|
.FirstOrDefault(); |
|
set { |
|
var matches = SessionVars.Where(kv=>kv.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)); |
|
if (matches.Count() == 0) { |
|
if (value is not null) { |
|
SessionVars.TryAdd( key, value ); |
|
RemovalTrackOff( key ); |
|
} |
|
} else { |
|
if (value is not null) { |
|
((IDictionary<string, object?>)SessionVars)[matches.First().Key] = value; |
|
RemovalTrackOff( matches.First().Key ); |
|
} else { |
|
SessionVars.Remove( matches.First().Key, out _ ); |
|
RemovalTrackOn( key ); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Tracks that a variable needs to be removed from the classic ASP session. |
|
/// </summary> |
|
private void RemovalTrackOn( string key ) { |
|
if (key == "removals") return; // ignore attempts to modify our tracking list |
|
if (this["removals"] is not List<string> removals) this["removals"] = removals = []; |
|
if (!removals.Contains( key, StringComparer.InvariantCultureIgnoreCase )) removals.Add( key ); |
|
} |
|
|
|
/// <summary> |
|
/// Stops tracking a variable for removal from classic ASP session. |
|
/// </summary> |
|
private void RemovalTrackOff( string key ) { |
|
if (key == "removals") return; // ignore attempts to modify our tracking list |
|
if (this["removals"] is not List<string> removals) return; |
|
var index = removals.FindIndex(x=>x.Equals(key, StringComparison.InvariantCultureIgnoreCase)); |
|
if (index > -1) removals.RemoveAt( index ); |
|
} |
|
|
|
/// <summary> |
|
/// Used by scoped service factory to initialize on first use during request to minimize calls to session.asp if not used during request. |
|
/// </summary> |
|
internal static Session Fetch( HttpContext context ) { |
|
Session? session = null; |
|
SyncClassicASP( context, async w => { |
|
var result = await w.GetAsync(""); |
|
session = await Hydrate( result ); |
|
return result; |
|
} ); |
|
return session ?? new Session(); |
|
} |
|
|
|
/// <summary> |
|
/// MERGE local changes, CLEAR or ABANDON the real classicASP session. |
|
/// Only use this if you actually need to persist a change back to classicASP. |
|
/// </summary> |
|
public void Push( HttpContext context, PushMode pushMode ) { |
|
var hrm = new HttpRequestMessage { |
|
Method = new HttpMethod(pushMode.ToString().ToUpper()), |
|
Content = JsonContent.Create(SessionVars) |
|
}; |
|
var x = hrm.Content.ReadAsStringAsync().GetAwaiter().GetResult(); |
|
hrm.Content.Headers.ContentType = new Headers.MediaTypeHeaderValue( "application/json" ); |
|
SyncClassicASP( context, async w => { |
|
var result = await w.SendAsync( hrm ); |
|
//TODO: clear and abandon are causing me trouble in the page because the standard elements from #includes aren't reconstituted until next request from classic.asp |
|
await Hydrate( result, this ); // updated ourself with fresh JSON from the response |
|
return result; |
|
} ); |
|
} |
|
|
|
/// <summary> |
|
/// Gets JSON from response and deserializes to new or provided Session instance. |
|
/// </summary> |
|
private static async Task<Session> Hydrate( HttpResponseMessage response, Session? instance = null ) { |
|
var json = await response.Content.ReadAsStringAsync(); |
|
if (string.IsNullOrWhiteSpace( json )) return new Session(); |
|
var data = JsonSerializer.Deserialize<Session?>( json, ClassicASP.JsonOptions ) ?? new Session(); |
|
if (instance != null) { |
|
instance.SessionId = data.SessionId; |
|
instance.SessionVars = data.SessionVars; |
|
} |
|
return instance ?? data; |
|
} |
|
|
|
/// <summary> |
|
/// Common behavior for syncing to session.asp. |
|
/// </summary> |
|
private static void SyncClassicASP( HttpContext context, Func<HttpClient, Task<HttpResponseMessage>> action ) { |
|
//figure out the classic ASP session cookies (there can be multiple, so send them all) |
|
var aspcookies = context.Request.Cookies.Keys.Where(c => c.StartsWith("ASPSESSION")); |
|
var cookie = aspcookies.Count() > 0 ? string.Join("; ", aspcookies.Select(c => $"{c}={context.Request.Cookies[c]}").ToArray()) : null; |
|
|
|
//build URI for the request (same server), with special handling for local-dev reverse-proxy because rewritten host hangs the whole application pool! |
|
var host = context.Request.Host.Host == "companion7" ? "nat-dev.driftershideout.com" : context.Request.Host.Host; |
|
var uri = new Uri($"{context.Request.Scheme}://{host}:{context.Request.Host.Port}/internal/session.asp"); |
|
|
|
//hack to not fail on localhost SSL cert: https://stackoverflow.com/a/14580179 |
|
//if (uri.Host.ToLower() == "localhost") ServicePointManager.ServerCertificateValidationCallback += (o, c, ch, er) => true; |
|
//BETTER: copy the localhost certificate to your trusted root store: https://stackoverflow.com/a/32788265 |
|
//BEST: as above, but export localhost cert from personal certificates, without private key, then import that into trusted root certificates |
|
|
|
//prepare HttpClient with common settings |
|
var localIP = IPAddress.Parse(context.GetServerVariable("LOCAL_ADDR")!); |
|
using var client = HttpClientForIP(localIP); |
|
if (cookie != null) client.DefaultRequestHeaders.Add( HeaderNames.Cookie, cookie ); |
|
client.BaseAddress = uri; |
|
|
|
//TODO: If we decide to try and re-use a single instance to align with best-practices we would need to also ensure cookie header is set on HttpRequestMessage |
|
// rather than on the HttpContext instance. To enable re-use we can use a private static variable and ??= the variable with a call to HttpClientForIP(). |
|
// The only concern with a long-lived instance is not getting DNS updates, but that's not an issue here since we use IP. Apparently the issue with newing |
|
// up instances constantly is socket exhaustion because sockets are not immediately released after dispose. Not a big concern with our low traffic rates. |
|
// REF: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests |
|
|
|
//caller can do what they want with the client now |
|
var response = action(client).GetAwaiter().GetResult(); |
|
// NOTE: Task.GetAwaiter().GetResult() throws actual exceptions, whereas .Result() or .Wait() wraps the exception: |
|
// REF: https://youtu.be/zhCRX3B7qwY?si=Mlq9SguOuLQoMWAz&t=1899 (the whole video is good advice, if you haven't seen it) |
|
// NOTE: we didn't make this function async on purpose |
|
|
|
var x = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); |
|
if (!response.IsSuccessStatusCode) { |
|
//TODO: show the error someplace |
|
throw new Exception( $"Status {response.StatusCode}: {response.Content.ReadAsStringAsync().GetAwaiter().GetResult()}" ); |
|
} |
|
|
|
//forward any new cookies to our response to "keep" the new session if one was created during the request to session.asp |
|
response.Headers? |
|
.Where( h => h.Key.Equals( "Set-Cookie", StringComparison.OrdinalIgnoreCase ) ) |
|
.ToList() |
|
.ForEach( h => context.Response.Headers.Append( h.Key, new Microsoft.Extensions.Primitives.StringValues( (string?[]?)h.Value ) ) ); |
|
|
|
//example Set-Cookie for a classic ASP session |
|
//ASPSESSIONIDQEARCSAS=EBBBPDCBHBPENBBOLMCEHOOB; secure; path=/ |
|
//cookies can also have expires=xxx; max-age=xxx; and domain=xxx; |
|
//NOTE: we get a separate Set-Cookie header for each cookie being set |
|
} |
|
|
|
/// <summary> |
|
/// This helper gives us an HttpClient for a specific origin IP. |
|
/// Our production environment is a shared host and we need to ensure our origin IP is |
|
/// the same IP our requests are received on to match what session.asp is looking for. |
|
/// Use IHttpClientFactory instead to get an HttpClient if you don't need this behavior. |
|
/// </summary> |
|
//Copied from here: https://stackoverflow.com/a/66681784 |
|
private static HttpClient HttpClientForIP( IPAddress ip ) { |
|
if (IPAddress.Any.Equals( ip )) return new HttpClient(); |
|
|
|
var handler = new SocketsHttpHandler(); |
|
handler.ConnectCallback = async ( context, cancellationToken ) => { |
|
|
|
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); |
|
socket.Bind( new IPEndPoint( ip, 0 ) ); |
|
socket.NoDelay = true; |
|
try { |
|
await socket.ConnectAsync( context.DnsEndPoint, cancellationToken ).ConfigureAwait( false ); |
|
return new NetworkStream( socket, true ); |
|
} catch { |
|
socket.Dispose(); |
|
throw; |
|
} |
|
}; |
|
return new HttpClient( handler ); |
|
} |
|
} |
|
} |