Solution restructured to use multiple test projects
This commit is contained in:
20
src/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj
Normal file
20
src/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
|
||||
|
||||
<AssemblyName>amwd-common-aspnetcore</AssemblyName>
|
||||
<RootNamespace>AMWD.Common.AspNetCore</RootNamespace>
|
||||
|
||||
<NrtTagMatch>asp/v[0-9]*</NrtTagMatch>
|
||||
<PackageId>AMWD.Common.AspNetCore</PackageId>
|
||||
<Product>AM.WD Common Library for ASP.NET Core</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Unclassified.DeepConvert" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AMWD.Common.AspNetCore.Security.BasicAuthentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.AspNetCore.Authorization
|
||||
{
|
||||
/// <summary>
|
||||
/// A basic authentication as attribute to use for specific actions.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
||||
public class BasicAuthenticationAttribute : Attribute, IAsyncAuthorizationFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a username to validate.
|
||||
/// </summary>
|
||||
public string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a password to validate.
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a realm used on authentication header.
|
||||
/// </summary>
|
||||
public string Realm { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<BasicAuthenticationAttribute>>();
|
||||
try
|
||||
{
|
||||
var validatorResult = await TrySetHttpUser(context).ConfigureAwait(false);
|
||||
bool isAllowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType<AllowAnonymousAttribute>().Any();
|
||||
if (isAllowAnonymous)
|
||||
return;
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeaderValue))
|
||||
{
|
||||
SetAuthenticateRequest(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue);
|
||||
byte[] decoded = Convert.FromBase64String(authHeader.Parameter);
|
||||
string plain = Encoding.UTF8.GetString(decoded);
|
||||
|
||||
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
|
||||
string username = plain.Split(':').First();
|
||||
string password = plain[(username.Length + 1)..];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
if (Username == username && Password == password)
|
||||
return;
|
||||
}
|
||||
|
||||
if (validatorResult == null)
|
||||
SetAuthenticateRequest(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, $"Failed to execute the basic authentication attribute: {ex.InnerException?.Message ?? ex.Message}");
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAuthenticateRequest(AuthorizationFilterContext context)
|
||||
{
|
||||
var validator = context.HttpContext.RequestServices.GetService<IBasicAuthenticationValidator>();
|
||||
string realm = string.IsNullOrWhiteSpace(Realm)
|
||||
? string.IsNullOrWhiteSpace(validator?.Realm)
|
||||
? null
|
||||
: validator.Realm
|
||||
: Realm;
|
||||
|
||||
context.HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
if (!string.IsNullOrWhiteSpace(realm))
|
||||
context.HttpContext.Response.Headers.WWWAuthenticate = $"Basic realm=\"{realm.Trim().Replace("\"", "")}\"";
|
||||
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
private static async Task<ClaimsPrincipal> TrySetHttpUser(AuthorizationFilterContext context)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<BasicAuthenticationAttribute>>();
|
||||
try
|
||||
{
|
||||
if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeaderValue))
|
||||
{
|
||||
var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue);
|
||||
byte[] decoded = Convert.FromBase64String(authHeader.Parameter);
|
||||
string plain = Encoding.UTF8.GetString(decoded);
|
||||
|
||||
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
|
||||
string username = plain.Split(':').First();
|
||||
string password = plain[(username.Length + 1)..];
|
||||
|
||||
var validator = context.HttpContext.RequestServices.GetService<IBasicAuthenticationValidator>();
|
||||
if (validator == null)
|
||||
return null;
|
||||
|
||||
var result = await validator.ValidateAsync(username, password, context.HttpContext.GetRemoteIpAddress(), context.HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
context.HttpContext.User = result;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, $"Using validator to get HTTP user failed: {ex.InnerException?.Message ?? ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Filters
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom filter attribute to use Google's reCaptcha (v3).
|
||||
/// <br/>
|
||||
/// Usage: [ServiceFilter(typeof(GoogleReCaptchaAttribute))]
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// appsettings.json:
|
||||
/// <br/>
|
||||
/// <code>
|
||||
/// {<br/>
|
||||
/// [...]<br/>
|
||||
/// "Google": {<br/>
|
||||
/// "ReCaptcha": {<br/>
|
||||
/// "PrivateKey": "__private reCaptcha key__",<br/>
|
||||
/// "PublicKey": "__public reCaptcha key__"<br/>
|
||||
/// }<br/>
|
||||
/// }<br/>
|
||||
/// }
|
||||
/// </code>
|
||||
/// <br/>
|
||||
/// The score from google can be found on HttpContext.Items[GoogleReCaptchaAttribute.ScoreKey].
|
||||
/// </remarks>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class GoogleReCaptchaAttribute : ActionFilterAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The error key used in <see cref="ActionContext.ModelState"/>.
|
||||
/// </summary>
|
||||
public const string ErrorKey = "GoogleReCaptcha";
|
||||
|
||||
/// <summary>
|
||||
/// The key used in forms submitted to the backend.
|
||||
/// </summary>
|
||||
public const string ResponseTokenKey = "g-recaptcha-response";
|
||||
|
||||
/// <summary>
|
||||
/// The key used in <see cref="Http.HttpContext.Items"/> to transport the score (0 - bot, 1 - human).
|
||||
/// </summary>
|
||||
public const string ScoreKey = "GoogleReCaptchaScore";
|
||||
|
||||
private const string VerificationUrl = "https://www.google.com/recaptcha/api/siteverify";
|
||||
|
||||
private string _privateKey;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the validattion in background.
|
||||
/// </summary>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="next">The following action delegate.</param>
|
||||
/// <returns>An awaitable task.</returns>
|
||||
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
||||
_privateKey = configuration?.GetValue<string>("Google:ReCaptcha:PrivateKey");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_privateKey))
|
||||
return;
|
||||
|
||||
await DoValidation(context).ConfigureAwait(false);
|
||||
await base.OnActionExecutionAsync(context, next).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DoValidation(ActionExecutingContext context)
|
||||
{
|
||||
if (!context.HttpContext.Request.HasFormContentType)
|
||||
return;
|
||||
|
||||
var token = context.HttpContext.Request.Form[ResponseTokenKey];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
context.ModelState.TryAddModelError(ErrorKey, "No token to validate Google reCaptcha");
|
||||
return;
|
||||
}
|
||||
|
||||
await Validate(context, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task Validate(ActionExecutingContext context, string token)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var param = new Dictionary<string, string>
|
||||
{
|
||||
{ "secret", _privateKey },
|
||||
{ "response", token }
|
||||
};
|
||||
var response = await httpClient.PostAsync(VerificationUrl, new FormUrlEncodedContent(param)).ConfigureAwait(false);
|
||||
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
var result = JsonConvert.DeserializeObject<Response>(json);
|
||||
if (result?.Success != true)
|
||||
{
|
||||
context.ModelState.TryAddModelError(ErrorKey, "Google reCaptcha verification failed");
|
||||
context.HttpContext.Items[ScoreKey] = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.HttpContext.Items[ScoreKey] = result.Score;
|
||||
}
|
||||
}
|
||||
|
||||
private class Response
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public decimal Score { get; set; }
|
||||
|
||||
public string Action { get; set; }
|
||||
|
||||
[JsonProperty("challenge_ts")]
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public string Hostname { get; set; }
|
||||
|
||||
[JsonProperty("error-codes")]
|
||||
public List<string> ErrorCodes { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs
Normal file
108
src/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Filters
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements an IP filter. Only defined addresses are allowed to access.
|
||||
/// </summary>
|
||||
public class IPAllowListAttribute : ActionFilterAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether local (localhost) access is granted (Default: true).
|
||||
/// </summary>
|
||||
public bool AllowLocalAccess { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a configuration key where the allowed IP addresses are allowed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// JSON configuration example:<br/>
|
||||
/// {<br/>
|
||||
/// "ConfigurationKey": [<br/>
|
||||
/// "10.0.0.0/8",<br/>
|
||||
/// "172.16.0.0/12",<br/>
|
||||
/// "fd00:123:abc::13"<br/>
|
||||
/// ]<br/>
|
||||
/// }
|
||||
/// </remarks>
|
||||
public string ConfigurationKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a comma separated list of allowed IP addresses.
|
||||
/// </summary>
|
||||
public string AllowedIpAddresses { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
base.OnActionExecuting(context);
|
||||
context.HttpContext.Items["RemoteAddress"] = context.HttpContext.GetRemoteIpAddress();
|
||||
|
||||
if (AllowLocalAccess && context.HttpContext.IsLocalRequest())
|
||||
return;
|
||||
|
||||
var remoteIpAddress = context.HttpContext.GetRemoteIpAddress();
|
||||
if (!string.IsNullOrWhiteSpace(AllowedIpAddresses))
|
||||
{
|
||||
string[] ipAddresses = AllowedIpAddresses.Split(',');
|
||||
foreach (string ipAddress in ipAddresses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||
continue;
|
||||
|
||||
if (MatchesIpAddress(ipAddress, remoteIpAddress))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
||||
if (!string.IsNullOrWhiteSpace(ConfigurationKey) && configuration != null)
|
||||
{
|
||||
var section = configuration.GetSection(ConfigurationKey);
|
||||
if (!section.Exists())
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in section.GetChildren())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(child.Value))
|
||||
continue;
|
||||
|
||||
if (MatchesIpAddress(child.Value, remoteIpAddress))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
private static bool MatchesIpAddress(string configIpAddress, IPAddress remoteIpAddress)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (configIpAddress.Contains('/'))
|
||||
{
|
||||
string[] ipNetworkParts = configIpAddress.Split('/');
|
||||
var ip = IPAddress.Parse(ipNetworkParts.First());
|
||||
int prefix = int.Parse(ipNetworkParts.Last());
|
||||
|
||||
var net = new IPNetwork(ip, prefix);
|
||||
return net.Contains(remoteIpAddress);
|
||||
}
|
||||
|
||||
return IPAddress.Parse(configIpAddress).Equals(remoteIpAddress);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs
Normal file
109
src/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Filters
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements an IP filter. The defined addresses are blocked.
|
||||
/// </summary>
|
||||
public class IPBlockListAttribute : ActionFilterAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether local (localhost) access is blocked (Default: false).
|
||||
/// </summary>
|
||||
public bool BlockLocalAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a configuration key where the blocked IP addresses are defined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// JSON configuration example:<br/>
|
||||
/// {<br/>
|
||||
/// "ConfigurationKey": [<br/>
|
||||
/// "10.0.0.0/8",<br/>
|
||||
/// "172.16.0.0/12",<br/>
|
||||
/// "fd00:123:abc::13"<br/>
|
||||
/// ]<br/>
|
||||
/// }
|
||||
/// </remarks>
|
||||
public string ConfigurationKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a comma separated list of blocked IP addresses.
|
||||
/// </summary>
|
||||
public string BlockedIpAddresses { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
base.OnActionExecuting(context);
|
||||
context.HttpContext.Items["RemoteAddress"] = context.HttpContext.GetRemoteIpAddress();
|
||||
|
||||
if (!BlockLocalAccess && context.HttpContext.IsLocalRequest())
|
||||
return;
|
||||
|
||||
var remoteIpAddress = context.HttpContext.GetRemoteIpAddress();
|
||||
if (!string.IsNullOrWhiteSpace(BlockedIpAddresses))
|
||||
{
|
||||
string[] ipAddresses = BlockedIpAddresses.Split(',');
|
||||
foreach (string ipAddress in ipAddresses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||
continue;
|
||||
|
||||
if (MatchesIpAddress(ipAddress, remoteIpAddress))
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
||||
if (!string.IsNullOrWhiteSpace(ConfigurationKey) && configuration != null)
|
||||
{
|
||||
var section = configuration.GetSection(ConfigurationKey);
|
||||
if (!section.Exists())
|
||||
return;
|
||||
|
||||
foreach (var child in section.GetChildren())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(child.Value))
|
||||
continue;
|
||||
|
||||
if (MatchesIpAddress(child.Value, remoteIpAddress))
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesIpAddress(string configIpAddress, IPAddress remoteIpAddress)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (configIpAddress.Contains('/'))
|
||||
{
|
||||
string[] ipNetworkParts = configIpAddress.Split('/');
|
||||
var ip = IPAddress.Parse(ipNetworkParts.First());
|
||||
int prefix = int.Parse(ipNetworkParts.Last());
|
||||
|
||||
var net = new IPNetwork(ip, prefix);
|
||||
return net.Contains(remoteIpAddress);
|
||||
}
|
||||
|
||||
return IPAddress.Parse(configIpAddress).Equals(remoteIpAddress);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="IApplicationBuilder"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds settings to run behind a reverse proxy (e.g. NginX).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A base path (e.g. running in a sub-directory /app) for the application can be defined via <c>ASPNETCORE_APPL_PATH</c> environment variable.
|
||||
/// <br/>
|
||||
/// <br/>
|
||||
/// Additionally you can specify the proxy server by using <paramref name="address"/> or a <paramref name="network"/> when there are multiple proxy servers.
|
||||
/// <br/>
|
||||
/// When neither <paramref name="address"/> nor <paramref name="network"/> is set, the default subnets are configured:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>127.0.0.0/8</c></item>
|
||||
/// <item><c>::1/128</c></item>
|
||||
///
|
||||
/// <item><c>10.0.0.0/8</c></item>
|
||||
/// <item><c>172.16.0.0/12</c></item>
|
||||
/// <item><c>192.168.0.0/16</c></item>
|
||||
/// <item><c>fd00::/8</c></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <param name="network">The <see cref="IPNetwork"/> where proxy requests are received from (optional).</param>
|
||||
/// <param name="address">The <see cref="IPAddress"/> where proxy requests are received from (optional).</param>
|
||||
/// <param name="basePath">A custom base path (optional, <c>ASPNETCORE_APPL_PATH</c> is prefererred).</param>
|
||||
public static IApplicationBuilder UseProxyHosting(this IApplicationBuilder app, IPNetwork network = null, IPAddress address = null, string basePath = null)
|
||||
{
|
||||
string path = Environment.GetEnvironmentVariable("ASPNETCORE_APPL_PATH");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = basePath;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
app.UsePathBase(new PathString(path));
|
||||
|
||||
var options = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
options.KnownProxies.Clear();
|
||||
options.KnownNetworks.Clear();
|
||||
|
||||
if (network == null && address == null)
|
||||
{
|
||||
// localhost
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.Loopback, 8));
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.IPv6Loopback, 128));
|
||||
|
||||
// private IPv4 networks
|
||||
// see https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
|
||||
|
||||
// private IPv6 networks
|
||||
// see https://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses
|
||||
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("fd00::"), 8));
|
||||
}
|
||||
|
||||
if (network != null)
|
||||
options.KnownNetworks.Add(network);
|
||||
|
||||
if (address != null)
|
||||
options.KnownProxies.Add(address);
|
||||
|
||||
app.UseForwardedHeaders(options);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/AMWD.Common.AspNetCore/Extensions/HtmlExtensions.cs
Normal file
81
src/AMWD.Common.AspNetCore/Extensions/HtmlExtensions.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the HTML (e.g. <see cref="IHtmlHelper"/>).
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class HtmlExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix used to identify JavaScript parts.
|
||||
/// </summary>
|
||||
public static string JSPrefix { get; set; } = "_JS_";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix used to identify CascadingStyleSheet parts.
|
||||
/// </summary>
|
||||
public static string CSSPrefix { get; set; } = "_CSS_";
|
||||
|
||||
/// <summary>
|
||||
/// Add a js snippet.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The dynamic type of the <see cref="IHtmlHelper"/>.</typeparam>
|
||||
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/> instance.</param>
|
||||
/// <param name="template">The template to use to add the snippet.</param>
|
||||
/// <returns></returns>
|
||||
public static T AddJS<T>(this IHtmlHelper<T> htmlHelper, Func<object, HelperResult> template)
|
||||
{
|
||||
htmlHelper.ViewContext.HttpContext.Items[$"{JSPrefix}{Guid.NewGuid()}"] = template;
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the js snippets into the view.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The dynamic type of the <see cref="IHtmlHelper"/>.</typeparam>
|
||||
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/> instance.</param>
|
||||
/// <returns></returns>
|
||||
public static T RenderJS<T>(this IHtmlHelper<T> htmlHelper)
|
||||
{
|
||||
foreach (object key in htmlHelper.ViewContext.HttpContext.Items.Keys)
|
||||
{
|
||||
if (key.ToString().StartsWith(JSPrefix) && htmlHelper.ViewContext.HttpContext.Items[key] is Func<object, HelperResult> template)
|
||||
htmlHelper.ViewContext.Writer.WriteLine(template(null));
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a css snippet.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The dynamic type of the <see cref="IHtmlHelper"/>.</typeparam>
|
||||
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/> instance.</param>
|
||||
/// <param name="template">The template to use to add the snippet.</param>
|
||||
/// <returns></returns>
|
||||
public static T AddCSS<T>(this IHtmlHelper<T> htmlHelper, Func<object, HelperResult> template)
|
||||
{
|
||||
htmlHelper.ViewContext.HttpContext.Items[$"{CSSPrefix}{Guid.NewGuid()}"] = template;
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the css snippets into the view.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The dynamic type of the <see cref="IHtmlHelper"/>.</typeparam>
|
||||
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/> instance.</param>
|
||||
/// <returns></returns>
|
||||
public static T RenderCSS<T>(this IHtmlHelper<T> htmlHelper)
|
||||
{
|
||||
foreach (object key in htmlHelper.ViewContext.HttpContext.Items.Keys)
|
||||
{
|
||||
if (key.ToString().StartsWith(CSSPrefix) && htmlHelper.ViewContext.HttpContext.Items[key] is Func<object, HelperResult> template)
|
||||
htmlHelper.ViewContext.Writer.WriteLine(template(null));
|
||||
}
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs
Normal file
124
src/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.AspNetCore.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="HttpContext"/>.
|
||||
/// </summary>
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
// Search these additional headers for a remote client ip address.
|
||||
private static readonly string[] _defaultIpHeaderNames =
|
||||
[
|
||||
"Cf-Connecting-Ip", // set by Cloudflare
|
||||
"X-Real-IP", // wide-spread alternative to X-Forwarded-For
|
||||
"X-Forwarded-For", // commonly used on all known proxies
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the antiforgery token.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
|
||||
/// <returns>FormName, HeaderName and Value of the antiforgery token.</returns>
|
||||
public static (string FormName, string HeaderName, string Value) GetAntiforgeryToken(this HttpContext httpContext)
|
||||
{
|
||||
var antiforgery = httpContext.RequestServices.GetService<IAntiforgery>();
|
||||
var tokenSet = antiforgery?.GetAndStoreTokens(httpContext);
|
||||
|
||||
return (tokenSet?.FormFieldName, tokenSet?.HeaderName, tokenSet?.RequestToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the remote ip address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Searches for additional headers in the following order:
|
||||
/// <list type="number">
|
||||
/// <item>Cf-Connecting-Ip</item>
|
||||
/// <item>X-Real-IP</item>
|
||||
/// <item>X-Forwarded-For</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
|
||||
/// <param name="ipHeaderName">The name of the header to resolve the <see cref="IPAddress"/> when behind a proxy.</param>
|
||||
/// <returns>The ip address of the client.</returns>
|
||||
public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string ipHeaderName = null)
|
||||
{
|
||||
string forwardedForAddress = null;
|
||||
|
||||
var headerNames = string.IsNullOrWhiteSpace(ipHeaderName)
|
||||
? _defaultIpHeaderNames
|
||||
: new[] { ipHeaderName }.Concat(_defaultIpHeaderNames);
|
||||
foreach (string headerName in headerNames)
|
||||
{
|
||||
if (!httpContext.Request.Headers.ContainsKey(headerName))
|
||||
continue;
|
||||
|
||||
// X-Forwarded-For can contain multiple comma-separated addresses.
|
||||
forwardedForAddress = httpContext.Request.Headers[headerName].ToString()
|
||||
.Split(',', StringSplitOptions.TrimEntries)
|
||||
.First();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(forwardedForAddress) && IPAddress.TryParse(forwardedForAddress, out var remoteAddress))
|
||||
{
|
||||
return remoteAddress.IsIPv4MappedToIPv6
|
||||
? remoteAddress.MapToIPv4()
|
||||
: remoteAddress;
|
||||
}
|
||||
|
||||
return httpContext.Connection.RemoteIpAddress.IsIPv4MappedToIPv6
|
||||
? httpContext.Connection.RemoteIpAddress.MapToIPv4()
|
||||
: httpContext.Connection.RemoteIpAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the request was made locally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Searches for additional headers in the following order:
|
||||
/// <list type="number">
|
||||
/// <item>Cf-Connecting-Ip</item>
|
||||
/// <item>X-Real-IP</item>
|
||||
/// <item>X-Forwarded-For</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
|
||||
/// <param name="ipHeaderName">The name of the header to resolve the <see cref="IPAddress"/> when behind a proxy.</param>
|
||||
/// <returns></returns>
|
||||
public static bool IsLocalRequest(this HttpContext httpContext, string ipHeaderName = null)
|
||||
{
|
||||
var remoteIpAddress = httpContext.GetRemoteIpAddress(ipHeaderName);
|
||||
return httpContext.Connection.LocalIpAddress.Equals(remoteIpAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to retrieve the return url.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
|
||||
/// <returns></returns>
|
||||
public static string GetReturnUrl(this HttpContext httpContext)
|
||||
{
|
||||
if (httpContext.Items.ContainsKey("OriginalRequest"))
|
||||
return httpContext.Items["OriginalRequest"].ToString();
|
||||
|
||||
if (httpContext.Request.Query.ContainsKey("ReturnUrl"))
|
||||
return httpContext.Request.Query["ReturnUrl"].ToString();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a session when available.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
|
||||
public static void ClearSession(this HttpContext httpContext)
|
||||
=> httpContext.Session?.Clear();
|
||||
}
|
||||
}
|
||||
99
src/AMWD.Common.AspNetCore/Extensions/LoggerExtensions.cs
Normal file
99
src/AMWD.Common.AspNetCore/Extensions/LoggerExtensions.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
|
||||
|
||||
namespace Microsoft.Extensions.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="ILogger"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal static class LoggerExtensions
|
||||
{
|
||||
// Found here:
|
||||
// https://github.com/dotnet/aspnetcore/blob/a4c45262fb8549bdb4f5e4f76b16f98a795211ae/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs
|
||||
|
||||
public static void AttemptingToBindModel(this ILogger logger, ModelBindingContext bindingContext)
|
||||
{
|
||||
if (!logger.IsEnabled(LogLevel.Debug))
|
||||
return;
|
||||
|
||||
var modelMetadata = bindingContext.ModelMetadata;
|
||||
switch (modelMetadata.MetadataKind)
|
||||
{
|
||||
case ModelMetadataKind.Parameter:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(44, "AttemptingToBindParameterModel"),
|
||||
$"Attempting to bind parameter '{modelMetadata.ParameterName}' of type '{modelMetadata.ModelType}' using the name '{bindingContext.ModelName}' in request data ...");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Property:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(13, "AttemptingToBindPropertyModel"),
|
||||
$"Attempting to bind property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{modelMetadata.ModelType}' using the name '{bindingContext.ModelName}' in request data ...");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Type:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(24, "AttemptingToBindModel"),
|
||||
$"Attempting to bind model of type '{bindingContext.ModelType}' using the name '{bindingContext.ModelName}' in request data ...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void FoundNoValueInRequest(this ILogger logger, ModelBindingContext bindingContext)
|
||||
{
|
||||
if (!logger.IsEnabled(LogLevel.Debug))
|
||||
return;
|
||||
|
||||
var modelMetadata = bindingContext.ModelMetadata;
|
||||
switch (modelMetadata.MetadataKind)
|
||||
{
|
||||
case ModelMetadataKind.Parameter:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(16, "FoundNoValueForParameterInRequest"),
|
||||
$"Could not find a value in the request with name '{bindingContext.ModelName}' for binding parameter '{modelMetadata.ParameterName}' of typ//(('{bindingContext.ModelType}'.");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Property:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(15, "FoundNoValueForPropertyInRequest"),
|
||||
$"Could not find a value in the request with name '{bindingContext.ModelName}' for binding property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{bindingContext.ModelType}'.");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Type:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(46, "FoundNoValueInRequest"),
|
||||
$"Could not find a value in the request with name '{bindingContext.ModelName}' of type '{bindingContext.ModelType}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void DoneAttemptingToBindModel(this ILogger logger, ModelBindingContext bindingContext)
|
||||
{
|
||||
if (!logger.IsEnabled(LogLevel.Debug))
|
||||
return;
|
||||
|
||||
var modelMetadata = bindingContext.ModelMetadata;
|
||||
switch (modelMetadata.MetadataKind)
|
||||
{
|
||||
case ModelMetadataKind.Parameter:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(45, "DoneAttemptingToBindParameterModel"),
|
||||
$"Done attempting to bind parameter '{modelMetadata.ParameterName}' of type '{modelMetadata.ModelType}'.");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Property:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(14, "DoneAttemptingToBindPropertyModel"),
|
||||
$"Done attempting to bind property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{modelMetadata.ModelType}'.");
|
||||
break;
|
||||
|
||||
case ModelMetadataKind.Type:
|
||||
logger.Log(LogLevel.Debug,
|
||||
new EventId(25, "DoneAttemptingToBindModel"),
|
||||
$"Done attempting to bind model of type '{bindingContext.ModelType}' using the name '{bindingContext.ModelName}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for the ASP.NET Core application.
|
||||
/// </summary>
|
||||
public static class ModelStateDictionaryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the specified <paramref name="errorMessage"/> to the <see cref="ModelStateEntry.Errors"/>
|
||||
/// instance that is associated with the key specified as a <see cref="MemberExpression"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The type of the model.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property.</typeparam>
|
||||
/// <param name="modelState">The <see cref="ModelStateDictionary"/> instance.</param>
|
||||
/// <param name="_">The model. Only used to infer the model type.</param>
|
||||
/// <param name="keyExpression">The <see cref="MemberExpression"/> that specifies the property.</param>
|
||||
/// <param name="errorMessage">The error message to add.</param>
|
||||
/// <exception cref="InvalidOperationException">No member expression provided.</exception>
|
||||
public static void AddModelError<TModel, TProperty>(this ModelStateDictionary modelState, TModel _, Expression<Func<TModel, TProperty>> keyExpression, string errorMessage)
|
||||
{
|
||||
if (modelState is null)
|
||||
throw new ArgumentNullException(nameof(modelState));
|
||||
|
||||
string key = "";
|
||||
var expr = keyExpression.Body as MemberExpression;
|
||||
while (expr != null)
|
||||
{
|
||||
key = expr.Member.Name + (key != "" ? "." + key : "");
|
||||
expr = expr.Expression as MemberExpression;
|
||||
}
|
||||
if (key == "")
|
||||
throw new InvalidOperationException("No member expression provided.");
|
||||
|
||||
modelState.AddModelError(key, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a hosted service that is instanciated only once.
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">The type of the service to add.</typeparam>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
|
||||
/// <returns>A reference to this instance after the operation has completed.</returns>
|
||||
public static IServiceCollection AddSingletonHostedService<TService>(this IServiceCollection services)
|
||||
where TService : class, IHostedService
|
||||
{
|
||||
services.AddSingleton<TService>();
|
||||
services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService<TService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a hosted service that is instanciated only once.
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">The type of the service to add.</typeparam>
|
||||
/// <typeparam name="TImplementation">The type of the implementation of the service to add.</typeparam>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
|
||||
/// <returns>A reference to this instance after the operation has completed.</returns>
|
||||
public static IServiceCollection AddSingletonHostedService<TService, TImplementation>(this IServiceCollection services)
|
||||
where TService : class, IHostedService
|
||||
where TImplementation : class, TService
|
||||
{
|
||||
services.AddSingleton<TService, TImplementation>();
|
||||
services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService<TService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/AMWD.Common.AspNetCore/Extensions/SessionExtensions.cs
Normal file
56
src/AMWD.Common.AspNetCore/Extensions/SessionExtensions.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Microsoft.AspNetCore.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="ISession"/> object.
|
||||
/// </summary>
|
||||
public static class SessionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets a strong typed value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="session">The current session.</param>
|
||||
/// <param name="key">The key.</param>
|
||||
/// <param name="value">The value.</param>
|
||||
public static void SetValue<T>(this ISession session, string key, T value)
|
||||
=> session.SetString(key, JsonConvert.SerializeObject(value));
|
||||
|
||||
/// <summary>
|
||||
/// Gets a strong typed value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="session">The current session.</param>
|
||||
/// <param name="key">The key.</param>
|
||||
/// <returns>The value.</returns>
|
||||
public static T GetValue<T>(this ISession session, string key)
|
||||
=> session.HasKey(key) ? JsonConvert.DeserializeObject<T>(session.GetString(key)) : default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a strong typed value or the fallback value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="session">The current session.</param>
|
||||
/// <param name="key">The key.</param>
|
||||
/// <param name="fallback">A fallback value when the key is not present.</param>
|
||||
/// <returns>The value.</returns>
|
||||
public static T GetValue<T>(this ISession session, string key, T fallback)
|
||||
{
|
||||
if (session.HasKey(key))
|
||||
return session.GetValue<T>(key);
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the session has the key available.
|
||||
/// </summary>
|
||||
/// <param name="session">The current session.</param>
|
||||
/// <param name="key">The key.</param>
|
||||
/// <returns><c>true</c> when the key was found, otherwise <c>false</c>.</returns>
|
||||
public static bool HasKey(this ISession session, string key)
|
||||
=> session.Keys.Contains(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom floating point ModelBinder as the team of Microsoft is not capable of fixing their <see href="https://github.com/dotnet/aspnetcore/issues/6566">issue</see> with other cultures than en-US.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of <see cref="InvariantFloatingPointModelBinder"/>.
|
||||
/// </remarks>
|
||||
/// <param name="supportedStyles">The <see cref="NumberStyles"/>.</param>
|
||||
/// <param name="cultureInfo">The <see cref="CultureInfo"/>.</param>
|
||||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class InvariantFloatingPointModelBinder(NumberStyles supportedStyles, CultureInfo cultureInfo, ILoggerFactory loggerFactory)
|
||||
: IModelBinder
|
||||
{
|
||||
private readonly NumberStyles _supportedNumberStyles = supportedStyles;
|
||||
private readonly ILogger _logger = loggerFactory?.CreateLogger<InvariantFloatingPointModelBinder>();
|
||||
private readonly CultureInfo _cultureInfo = cultureInfo ?? throw new ArgumentNullException(nameof(cultureInfo));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task BindModelAsync(ModelBindingContext bindingContext)
|
||||
{
|
||||
if (bindingContext == null)
|
||||
throw new ArgumentNullException(nameof(bindingContext));
|
||||
|
||||
_logger?.AttemptingToBindModel(bindingContext);
|
||||
string modelName = bindingContext.ModelName;
|
||||
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
|
||||
if (valueProviderResult == ValueProviderResult.None)
|
||||
{
|
||||
_logger?.FoundNoValueInRequest(bindingContext);
|
||||
|
||||
// no entry
|
||||
_logger?.DoneAttemptingToBindModel(bindingContext);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var modelState = bindingContext.ModelState;
|
||||
modelState.SetModelValue(modelName, valueProviderResult);
|
||||
|
||||
var metadata = bindingContext.ModelMetadata;
|
||||
var type = metadata.UnderlyingOrModelType;
|
||||
try
|
||||
{
|
||||
string value = valueProviderResult.FirstValue;
|
||||
var culture = _cultureInfo ?? valueProviderResult.Culture;
|
||||
|
||||
object model;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
// Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
|
||||
model = null;
|
||||
}
|
||||
else if (type == typeof(float))
|
||||
{
|
||||
model = float.Parse(value, _supportedNumberStyles, culture);
|
||||
}
|
||||
else if (type == typeof(double))
|
||||
{
|
||||
model = double.Parse(value, _supportedNumberStyles, culture);
|
||||
}
|
||||
else if (type == typeof(decimal))
|
||||
{
|
||||
model = decimal.Parse(value, _supportedNumberStyles, culture);
|
||||
}
|
||||
else
|
||||
{
|
||||
// unreachable
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// When converting value, a null model may indicate a failed conversion for an otherwise required
|
||||
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the
|
||||
// current bindingContext. If not, an error is logged.
|
||||
if (model == null && !metadata.IsReferenceOrNullableType)
|
||||
{
|
||||
modelState.TryAddModelError(
|
||||
modelName,
|
||||
metadata
|
||||
.ModelBindingMessageProvider
|
||||
.ValueMustNotBeNullAccessor(valueProviderResult.ToString())
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
bindingContext.Result = ModelBindingResult.Success(model);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
bool isFormatException = exception is FormatException;
|
||||
if (!isFormatException && exception.InnerException != null)
|
||||
{
|
||||
// Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve
|
||||
// this code in case a cursory review of the CoreFx code missed something.
|
||||
exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
|
||||
}
|
||||
|
||||
modelState.TryAddModelError(modelName, exception, metadata);
|
||||
// Conversion failed.
|
||||
}
|
||||
|
||||
_logger?.DoneAttemptingToBindModel(bindingContext);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.ModelBinding
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="IModelBinderProvider"/> for binding <see cref="decimal"/>, <see cref="double"/>,
|
||||
/// <see cref="float"/>, and their <see cref="Nullable{T}"/> wrappers.
|
||||
/// Modified to set <see cref="NumberStyles"/> and <see cref="System.Globalization.CultureInfo"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// To use this provider, insert it at the beginning of the providers list:<br/>
|
||||
/// <code>
|
||||
/// services.AddControllersWithViews(options =><br/>
|
||||
/// {<br/>
|
||||
/// options.ModelBinderProviders.Insert(0, new CustomFloatingPointModelBinderProvider());<br/>
|
||||
/// });</code>
|
||||
/// </remarks>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class InvariantFloatingPointModelBinderProvider : IModelBinderProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the supported <see cref="NumberStyles"/> globally.
|
||||
/// Default: <see cref="NumberStyles.Float"/> and <see cref="NumberStyles.AllowThousands"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SimpleTypeModelBinder"/> uses <see cref="DecimalConverter"/> and similar. Those <see cref="TypeConverter"/>s default to <see cref="NumberStyles.Float"/>.
|
||||
/// </remarks>
|
||||
public static NumberStyles SupportedNumberStyles { get; set; } = NumberStyles.Float | NumberStyles.AllowThousands;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="System.Globalization.CultureInfo"/> to use while parsing globally.
|
||||
/// Default: <see cref="CultureInfo.InvariantCulture"/>.
|
||||
/// </summary>
|
||||
public static CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IModelBinder GetBinder(ModelBinderProviderContext context)
|
||||
{
|
||||
if (context == null)
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
|
||||
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
|
||||
var modelType = context.Metadata.UnderlyingOrModelType;
|
||||
if (modelType == typeof(decimal) ||
|
||||
modelType == typeof(double) ||
|
||||
modelType == typeof(float))
|
||||
{
|
||||
return new InvariantFloatingPointModelBinder(SupportedNumberStyles, CultureInfo, loggerFactory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Implements the <see cref="AuthenticationHandler{TOptions}"/> for Basic Authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="BasicAuthenticationHandler"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="options" > The monitor for the options instance.</param>
|
||||
/// <param name="logger">The <see cref="ILoggerFactory"/>.</param>
|
||||
/// <param name="encoder">The <see cref="UrlEncoder"/>.</param>
|
||||
/// <param name="validator">An basic autentication validator implementation.</param>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IBasicAuthenticationValidator validator)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
#else
|
||||
/// <summary>
|
||||
/// Implements the <see cref="AuthenticationHandler{TOptions}"/> for Basic Authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="BasicAuthenticationHandler"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="options" > The monitor for the options instance.</param>
|
||||
/// <param name="logger">The <see cref="ILoggerFactory"/>.</param>
|
||||
/// <param name="encoder">The <see cref="UrlEncoder"/>.</param>
|
||||
/// <param name="clock">The <see cref="ISystemClock"/>.</param>
|
||||
/// <param name="validator">An basic autentication validator implementation.</param>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IBasicAuthenticationValidator validator)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock)
|
||||
#endif
|
||||
{
|
||||
private readonly ILogger _logger = logger.CreateLogger<BasicAuthenticationHandler>();
|
||||
private readonly IBasicAuthenticationValidator _validator = validator;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var endpoint = Context.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValue))
|
||||
return AuthenticateResult.Fail("Authorization header missing");
|
||||
|
||||
ClaimsPrincipal principal;
|
||||
try
|
||||
{
|
||||
var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue);
|
||||
string plain = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter));
|
||||
|
||||
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
|
||||
string username = plain.Split(':').First();
|
||||
string password = plain[(username.Length + 1)..];
|
||||
|
||||
var ipAddress = Context.GetRemoteIpAddress();
|
||||
principal = await _validator.ValidateAsync(username, password, ipAddress, Context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Handling the Basic Authentication failed: {ex.Message}");
|
||||
return AuthenticateResult.Fail("Authorization header invalid");
|
||||
}
|
||||
|
||||
if (principal == null)
|
||||
return AuthenticateResult.Fail("Invalid credentials");
|
||||
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a basic authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="BasicAuthenticationMiddleware"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="next">The following delegate in the process chain.</param>
|
||||
/// <param name="validator">A basic authentication validator.</param>
|
||||
public class BasicAuthenticationMiddleware(RequestDelegate next, IBasicAuthenticationValidator validator)
|
||||
{
|
||||
private readonly RequestDelegate _next = next;
|
||||
private readonly IBasicAuthenticationValidator _validator = validator;
|
||||
|
||||
/// <summary>
|
||||
/// The delegate invokation.
|
||||
/// Performs the authentication check.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The corresponding HTTP context.</param>
|
||||
/// <returns>An awaitable task.</returns>
|
||||
public async Task InvokeAsync(HttpContext httpContext)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
if (!httpContext.Request.Headers.TryGetValue("Authorization", out var authHeaderValue))
|
||||
{
|
||||
SetAuthenticateRequest(httpContext, _validator.Realm);
|
||||
return;
|
||||
}
|
||||
#else
|
||||
if (!httpContext.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
SetAuthenticateRequest(httpContext, _validator.Realm);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
try
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue);
|
||||
#else
|
||||
var authHeader = AuthenticationHeaderValue.Parse(httpContext.Request.Headers["Authorization"]);
|
||||
#endif
|
||||
byte[] decoded = Convert.FromBase64String(authHeader.Parameter);
|
||||
string plain = Encoding.UTF8.GetString(decoded);
|
||||
|
||||
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
|
||||
string username = plain.Split(':').First();
|
||||
string password = plain[(username.Length + 1)..];
|
||||
|
||||
var principal = await _validator.ValidateAsync(username, password, httpContext.GetRemoteIpAddress(), httpContext.RequestAborted).ConfigureAwait(false);
|
||||
if (principal == null)
|
||||
{
|
||||
SetAuthenticateRequest(httpContext, _validator.Realm);
|
||||
return;
|
||||
}
|
||||
|
||||
await _next.Invoke(httpContext).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = (ILogger<BasicAuthenticationMiddleware>)httpContext.RequestServices.GetService(typeof(ILogger<BasicAuthenticationMiddleware>));
|
||||
logger?.LogError(ex, $"Falied to execute basic authentication middleware: {ex.InnerException?.Message ?? ex.Message}");
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAuthenticateRequest(HttpContext httpContext, string realm)
|
||||
{
|
||||
httpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
if (!string.IsNullOrWhiteSpace(realm))
|
||||
httpContext.Response.Headers.WWWAuthenticate = $"Basic realm=\"{realm.Replace("\"", "")}\"";
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface representing the validation of a basic authentication.
|
||||
/// </summary>
|
||||
public interface IBasicAuthenticationValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the realm to use when requesting authentication.
|
||||
/// </summary>
|
||||
string Realm { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates a username and password for Basic Authentication.
|
||||
/// </summary>
|
||||
/// <param name="username">A username.</param>
|
||||
/// <param name="password">A password.</param>
|
||||
/// <param name="remoteAddress">The remote client's IP address.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The validated user principal or <c>null</c>.</returns>
|
||||
Task<ClaimsPrincipal> ValidateAsync(string username, string password, IPAddress remoteAddress, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.PathProtection
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension for <see cref="IApplicationBuilder"/> to enable folder protection.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class ProtectedPathExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Provide protected paths even for static files.
|
||||
/// </summary>
|
||||
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
|
||||
/// <param name="options">The <see cref="ProtectedPathOptions"/> with path and policy name.</param>
|
||||
public static IApplicationBuilder UseProtectedPath(this IApplicationBuilder app, ProtectedPathOptions options)
|
||||
=> app.UseMiddleware<ProtectedPathMiddleware>(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.PathProtection
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a check to provide protected paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="ProtectedPathExtensions"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="next">The following delegate in the process chain.</param>
|
||||
/// <param name="options">The options to configure the middleware.</param>
|
||||
public class ProtectedPathMiddleware(RequestDelegate next, ProtectedPathOptions options)
|
||||
{
|
||||
private readonly RequestDelegate _next = next;
|
||||
private readonly PathString _path = options.Path;
|
||||
private readonly string _policyName = options.PolicyName;
|
||||
|
||||
/// <summary>
|
||||
/// The delegate invokation.
|
||||
/// Performs the protection check.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The corresponding HTTP context.</param>
|
||||
/// <param name="authorizationService">The <see cref="IAuthorizationService"/>.</param>
|
||||
/// <returns>An awaitable task.</returns>
|
||||
public async Task InvokeAsync(HttpContext httpContext, IAuthorizationService authorizationService)
|
||||
{
|
||||
if (httpContext.Request.Path.StartsWithSegments(_path))
|
||||
{
|
||||
var result = await authorizationService.AuthorizeAsync(httpContext.User, null, _policyName).ConfigureAwait(false);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await httpContext.ChallengeAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _next.Invoke(httpContext).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Security.PathProtection
|
||||
{
|
||||
/// <summary>
|
||||
/// Options to define which folder should be protected.
|
||||
/// </summary>
|
||||
public class ProtectedPathOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the protected folder.
|
||||
/// </summary>
|
||||
public PathString Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the policy name to use.
|
||||
/// </summary>
|
||||
public string PolicyName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.AspNetCore.Razor.TagHelpers
|
||||
{
|
||||
// Source: https://stackoverflow.com/a/42385059
|
||||
|
||||
/// <summary>
|
||||
/// A tag helper that adds a CSS class attribute based on a condition.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
[HtmlTargetElement(Attributes = ClassPrefix + "*")]
|
||||
public class ConditionClassTagHelper : TagHelper
|
||||
{
|
||||
private const string ClassPrefix = "condition-class-";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unconditional CSS class attribute value of the element.
|
||||
/// </summary>
|
||||
[HtmlAttributeName("class")]
|
||||
public string CssClass { get; set; }
|
||||
|
||||
private IDictionary<string, bool> _classValues;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a dictionary containing all conditional class names and a boolean condition
|
||||
/// value indicating whether the class should be added to the element.
|
||||
/// </summary>
|
||||
[HtmlAttributeName("", DictionaryAttributePrefix = ClassPrefix)]
|
||||
public IDictionary<string, bool> ClassValues
|
||||
{
|
||||
get
|
||||
{
|
||||
return _classValues ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
set
|
||||
{
|
||||
_classValues = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously executes the <see cref="T:Microsoft.AspNetCore.Razor.TagHelpers.TagHelper"/>
|
||||
/// with the given <paramref name="context"/> and <paramref name="output"/>.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains information associated with the current HTML tag.</param>
|
||||
/// <param name="output">A stateful HTML element used to generate an HTML tag.</param>
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
var items = _classValues.Where(e => e.Value).Select(e => e.Key).ToList();
|
||||
if (!string.IsNullOrEmpty(CssClass))
|
||||
items.Insert(0, CssClass);
|
||||
|
||||
if (items.Any())
|
||||
{
|
||||
string classes = string.Join(" ", [.. items]);
|
||||
output.Attributes.Add("class", classes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/AMWD.Common.AspNetCore/TagHelpers/EmailTagHelper.cs
Normal file
39
src/AMWD.Common.AspNetCore/TagHelpers/EmailTagHelper.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
|
||||
namespace Microsoft.AspNetCore.Razor.TagHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// A tag helper to create a obfuscated email link.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
[HtmlTargetElement("email", TagStructure = TagStructure.WithoutEndTag)]
|
||||
public class EmailTagHelper : TagHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The e-mail address.
|
||||
/// </summary>
|
||||
[HtmlAttributeName("asp-address")]
|
||||
public string Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the element.
|
||||
/// </summary>
|
||||
/// <param name="context">The tag helper context.</param>
|
||||
/// <param name="output">The tag helper output.</param>
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes($"mailto:{Address}"));
|
||||
string reversed = new(Address.Reverse().ToArray());
|
||||
|
||||
output.TagName = "a";
|
||||
output.TagMode = TagMode.StartTagAndEndTag;
|
||||
output.Attributes.SetAttribute("href", new HtmlString($"javascript:window.location.href=atob('{base64}')"));
|
||||
output.Attributes.SetAttribute("style", "unicode-bidi: bidi-override; direction: rtl;");
|
||||
output.Attributes.RemoveAll("asp-address");
|
||||
output.Content.SetContent(reversed);
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs
Normal file
158
src/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Microsoft.AspNetCore.Razor.TagHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// A tag helper to dynamically create integrity checks for linked sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="IntegrityHashTagHelper"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="env">The web host environment.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
[HtmlTargetElement("link")]
|
||||
[HtmlTargetElement("script")]
|
||||
public class IntegrityHashTagHelper(IWebHostEnvironment env, IConfiguration configuration)
|
||||
: TagHelper
|
||||
{
|
||||
private readonly IWebHostEnvironment _env = env;
|
||||
private readonly string _hostUrl = configuration.GetValue("ASPNETCORE_APPL_URL", "http://localhost/");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the integrity should be calculated.
|
||||
/// </summary>
|
||||
[HtmlAttributeName("asp-integrity")]
|
||||
public bool IsIntegrityEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hash strength to use.
|
||||
/// </summary>
|
||||
[HtmlAttributeName("asp-integrity-strength")]
|
||||
public int IntegrityStrength { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
if (context.AllAttributes.Where(a => a.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase)).Any())
|
||||
return;
|
||||
|
||||
if (!IsIntegrityEnabled)
|
||||
return;
|
||||
|
||||
string source = null;
|
||||
switch (context.TagName.ToLower())
|
||||
{
|
||||
case "link":
|
||||
var rel = context.AllAttributes.Where(a => a.Name.ToLower() == "rel").FirstOrDefault();
|
||||
if (rel == null || rel.Value.ToString().ToLower() == "stylesheet")
|
||||
{
|
||||
var href = context.AllAttributes.Where(a => a.Name.ToLower() == "href").FirstOrDefault();
|
||||
source = href?.Value?.ToString().Trim();
|
||||
}
|
||||
break;
|
||||
case "script":
|
||||
var src = context.AllAttributes.Where(a => a.Name.ToLower() == "src").FirstOrDefault();
|
||||
source = src?.Value?.ToString().Trim();
|
||||
break;
|
||||
}
|
||||
|
||||
// no source given, no hash to calculate.
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
return;
|
||||
|
||||
byte[] fileBytes = null;
|
||||
if (source.StartsWith("http") || source.StartsWith("//"))
|
||||
{
|
||||
if (source.StartsWith("//"))
|
||||
source = $"http:{source}";
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_hostUrl))
|
||||
client.DefaultRequestHeaders.Referrer = new Uri(_hostUrl);
|
||||
|
||||
var response = await client.GetAsync(source).ConfigureAwait(false);
|
||||
fileBytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (source.StartsWith("~"))
|
||||
source = source[1..];
|
||||
|
||||
if (source.StartsWith("/"))
|
||||
source = source[1..];
|
||||
|
||||
if (source.Contains('?'))
|
||||
source = source[..source.IndexOf("?")];
|
||||
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(_env.WebRootPath, source);
|
||||
fileBytes = await File.ReadAllBytesAsync(path).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string type;
|
||||
byte[] hashBytes = [];
|
||||
switch (IntegrityStrength)
|
||||
{
|
||||
case 512:
|
||||
type = "sha512";
|
||||
using (var sha = SHA512.Create())
|
||||
{
|
||||
hashBytes = sha.ComputeHash(fileBytes);
|
||||
}
|
||||
break;
|
||||
case 384:
|
||||
type = "sha384";
|
||||
using (var sha = SHA384.Create())
|
||||
{
|
||||
hashBytes = sha.ComputeHash(fileBytes);
|
||||
}
|
||||
break;
|
||||
default: // 256
|
||||
type = "sha256";
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
hashBytes = sha.ComputeHash(fileBytes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
string hash = Convert.ToBase64String(hashBytes);
|
||||
|
||||
output.Attributes.RemoveAll("integrity");
|
||||
output.Attributes.RemoveAll("crossorigin");
|
||||
|
||||
output.Attributes.Add(new TagHelperAttribute("integrity", new HtmlString($"{type}-{hash}")));
|
||||
output.Attributes.Add(new TagHelperAttribute("crossorigin", new HtmlString("anonymous")));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
// ensure leaving context to prevent a deadlock.
|
||||
var task = Task.Run(() => ProcessAsync(context, output));
|
||||
task.Wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
185
src/AMWD.Common.AspNetCore/TagHelpers/NumberInputTagHelper.cs
Normal file
185
src/AMWD.Common.AspNetCore/TagHelpers/NumberInputTagHelper.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds additional behavior to the modelbinding for numeric properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="NumberInputTagHelper"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="generator">The HTML generator.</param>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
[HtmlTargetElement("input", Attributes = "asp-for")]
|
||||
public class NumberInputTagHelper(IHtmlGenerator generator)
|
||||
: InputTagHelper(generator)
|
||||
{
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
base.Process(context, output);
|
||||
|
||||
var types = new[] {
|
||||
typeof(byte), typeof(sbyte),
|
||||
typeof(ushort), typeof(short),
|
||||
typeof(uint), typeof(int),
|
||||
typeof(ulong), typeof(long),
|
||||
typeof(float),
|
||||
typeof(double),
|
||||
typeof(decimal)
|
||||
};
|
||||
|
||||
var typeAttributes = output.Attributes
|
||||
.Where(a => a.Name == "type")
|
||||
.ToList();
|
||||
string typeAttributeValue = typeAttributes.First().Value as string;
|
||||
|
||||
Type modelType = For.ModelExplorer.ModelType;
|
||||
Type nullableType = Nullable.GetUnderlyingType(modelType);
|
||||
|
||||
// the type itself or its nullable wrapper matching and
|
||||
// the type attribute is number or there is only one type attribute
|
||||
// IMPORTANT TO KNOW: if the type attribute is set in the view, there are two attributes with same value.
|
||||
if ((types.Contains(modelType) || types.Contains(nullableType)) && (typeAttributeValue == "number" || typeAttributes.Count == 1))
|
||||
{
|
||||
var culture = CultureInfo.InvariantCulture;
|
||||
string min = "";
|
||||
string max = "";
|
||||
string step = "";
|
||||
string value = "";
|
||||
|
||||
if (modelType == typeof(byte) || nullableType == typeof(byte))
|
||||
{
|
||||
min = byte.MinValue.ToString(culture);
|
||||
max = byte.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
byte val = (byte)For.Model;
|
||||
value = value.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(sbyte) || nullableType == typeof(sbyte))
|
||||
{
|
||||
min = sbyte.MinValue.ToString(culture);
|
||||
max = sbyte.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
sbyte val = (sbyte)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(ushort) || nullableType == typeof(ushort))
|
||||
{
|
||||
min = ushort.MinValue.ToString(culture);
|
||||
max = ushort.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
ushort val = (ushort)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(short) || nullableType == typeof(short))
|
||||
{
|
||||
min = short.MinValue.ToString(culture);
|
||||
max = short.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
short val = (short)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(uint) || nullableType == typeof(uint))
|
||||
{
|
||||
min = uint.MinValue.ToString(culture);
|
||||
max = uint.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
uint val = (uint)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(int) || nullableType == typeof(int))
|
||||
{
|
||||
min = int.MinValue.ToString(culture);
|
||||
max = int.MaxValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
int val = (int)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(ulong) || nullableType == typeof(ulong))
|
||||
{
|
||||
min = ulong.MinValue.ToString(culture);
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
ulong val = (ulong)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(long) || nullableType == typeof(long))
|
||||
{
|
||||
if (For.Model != null)
|
||||
{
|
||||
long val = (long)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(float) || nullableType == typeof(float))
|
||||
{
|
||||
step = "any";
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
float val = (float)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(double) || nullableType == typeof(double))
|
||||
{
|
||||
step = "any";
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
double val = (double)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
else if (modelType == typeof(decimal) || nullableType == typeof(decimal))
|
||||
{
|
||||
step = "any";
|
||||
|
||||
if (For.Model != null)
|
||||
{
|
||||
decimal val = (decimal)For.Model;
|
||||
value = val.ToString(culture);
|
||||
}
|
||||
}
|
||||
|
||||
output.Attributes.SetAttribute(new TagHelperAttribute("type", "number"));
|
||||
output.Attributes.SetAttribute(new TagHelperAttribute("value", value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(min) && !output.Attributes.ContainsName("min"))
|
||||
output.Attributes.SetAttribute(new TagHelperAttribute("min", min));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(max) && !output.Attributes.ContainsName("max"))
|
||||
output.Attributes.SetAttribute(new TagHelperAttribute("max", max));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(step) && !output.Attributes.ContainsName("step"))
|
||||
output.Attributes.SetAttribute(new TagHelperAttribute("step", step));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/AMWD.Common.AspNetCore/Utilities/HtmlHelper.cs
Normal file
55
src/AMWD.Common.AspNetCore/Utilities/HtmlHelper.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AMWD.Common.AspNetCore.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides helpers for webpages.
|
||||
/// </summary>
|
||||
public static class HtmlHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether a color is considered as dark.
|
||||
/// </summary>
|
||||
/// <param name="color">The color (hex or rgb - as defined in CSS)</param>
|
||||
/// <returns><c>true</c> when the color is dark otherwise <c>false</c>.</returns>
|
||||
public static bool IsDarkColor(string color)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(color))
|
||||
throw new ArgumentNullException(nameof(color));
|
||||
|
||||
int r, g, b;
|
||||
|
||||
var rgbMatch = Regex.Match(color, @"^rgba?\(([0-9]+), ?([0-9]+), ?([0-9]+)");
|
||||
var hexMatchFull = Regex.Match(color, @"^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$");
|
||||
var hexMatchLite = Regex.Match(color, @"^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$");
|
||||
|
||||
if (rgbMatch.Success)
|
||||
{
|
||||
r = Convert.ToInt32(rgbMatch.Groups[1].Value, 10);
|
||||
g = Convert.ToInt32(rgbMatch.Groups[2].Value, 10);
|
||||
b = Convert.ToInt32(rgbMatch.Groups[3].Value, 10);
|
||||
}
|
||||
else if (hexMatchFull.Success)
|
||||
{
|
||||
r = Convert.ToInt32(hexMatchFull.Groups[1].Value, 16);
|
||||
g = Convert.ToInt32(hexMatchFull.Groups[2].Value, 16);
|
||||
b = Convert.ToInt32(hexMatchFull.Groups[3].Value, 16);
|
||||
}
|
||||
else if (hexMatchLite.Success)
|
||||
{
|
||||
r = Convert.ToInt32(new string(hexMatchLite.Groups[1].Value.First(), 2), 16);
|
||||
g = Convert.ToInt32(new string(hexMatchLite.Groups[2].Value.First(), 2), 16);
|
||||
b = Convert.ToInt32(new string(hexMatchLite.Groups[3].Value.First(), 2), 16);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Unknown color value '{color}'");
|
||||
}
|
||||
|
||||
double luminance = (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
||||
return luminance <= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/AMWD.Common.AspNetCore/Utilities/PasswordHelper.cs
Normal file
52
src/AMWD.Common.AspNetCore/Utilities/PasswordHelper.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace Microsoft.AspNetCore.Identity
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides password hashing and verification methods.
|
||||
/// </summary>
|
||||
public static class PasswordHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Hashes a password.
|
||||
/// </summary>
|
||||
/// <param name="plainPassword">The plain password.</param>
|
||||
/// <returns></returns>
|
||||
public static string HashPassword(string plainPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plainPassword))
|
||||
return plainPassword?.Trim();
|
||||
|
||||
var ph = new PasswordHasher<object>();
|
||||
return ph.HashPassword(null, plainPassword.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a password with a hashed version.
|
||||
/// </summary>
|
||||
/// <param name="plainPassword">The plain password.</param>
|
||||
/// <param name="hashedPassword">The password hash.</param>
|
||||
/// <param name="rehashNeeded">A value indicating whether the password needs a rehash.</param>
|
||||
/// <returns></returns>
|
||||
public static bool VerifyPassword(string plainPassword, string hashedPassword, out bool rehashNeeded)
|
||||
{
|
||||
rehashNeeded = false;
|
||||
if (string.IsNullOrWhiteSpace(plainPassword) || string.IsNullOrWhiteSpace(hashedPassword))
|
||||
return false;
|
||||
|
||||
var ph = new PasswordHasher<object>();
|
||||
var result = ph.VerifyHashedPassword(null, hashedPassword, plainPassword);
|
||||
switch (result)
|
||||
{
|
||||
case PasswordVerificationResult.Success:
|
||||
return true;
|
||||
|
||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
rehashNeeded = true;
|
||||
return true;
|
||||
|
||||
case PasswordVerificationResult.Failed:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
|
||||
|
||||
<AssemblyName>amwd-common-efcore</AssemblyName>
|
||||
<RootNamespace>AMWD.Common.EntityFrameworkCore</RootNamespace>
|
||||
|
||||
<NrtTagMatch>efc/v[0-9]*</NrtTagMatch>
|
||||
<PackageId>AMWD.Common.EntityFrameworkCore</PackageId>
|
||||
<Product>AM.WD Common Library for EntityFramework Core</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using AMWD.Common.EntityFrameworkCore.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Attributes
|
||||
{
|
||||
/// <summary>
|
||||
/// Property attribute to create indices and unique constraints in the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires <see cref="ModelBuilderExtensions.ApplyIndexAttributes(ModelBuilder)"/> to be called within <see cref="DbContext.OnModelCreating(ModelBuilder)"/>.
|
||||
/// </remarks>
|
||||
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
|
||||
public class DatabaseIndexAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseIndexAttribute"/> class.
|
||||
/// </summary>
|
||||
public DatabaseIndexAttribute()
|
||||
{
|
||||
Name = null;
|
||||
IsUnique = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a name.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether it is a unique constraint.
|
||||
/// </summary>
|
||||
public bool IsUnique { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the conversion from a <see cref="DateOnly"/> object to a <see cref="DateTime"/> which can be handled by the database engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
public class DateOnlyConverter : ValueConverter<DateOnly, DateTime>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DateOnlyConverter"/> class.
|
||||
/// </summary>
|
||||
public DateOnlyConverter()
|
||||
: base(
|
||||
d => d.ToDateTime(TimeOnly.MinValue),
|
||||
d => DateOnly.FromDateTime(d)
|
||||
)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the conversion from a nullable <see cref="DateOnly"/> object to a nullable <see cref="DateTime"/> which can be handled by the database engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
public class NullableDateOnlyConverter : ValueConverter<DateOnly?, DateTime?>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NullableDateOnlyConverter"/> class.
|
||||
/// </summary>
|
||||
public NullableDateOnlyConverter()
|
||||
: base(
|
||||
d => d == null ? null : new DateTime?(d.Value.ToDateTime(TimeOnly.MinValue)),
|
||||
d => d == null ? null : new DateOnly?(DateOnly.FromDateTime(d.Value))
|
||||
)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the conversion from a nullable <see cref="TimeOnly"/> object to a nullable <see cref="TimeSpan"/> which can be handled by the database engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
public class NullableTimeOnlyConverter : ValueConverter<TimeOnly?, TimeSpan?>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NullableTimeOnlyConverter"/> class.
|
||||
/// </summary>
|
||||
public NullableTimeOnlyConverter()
|
||||
: base(
|
||||
d => d == null ? null : new TimeSpan?(d.Value.ToTimeSpan()),
|
||||
d => d == null ? null : new TimeOnly?(TimeOnly.FromTimeSpan(d.Value))
|
||||
)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the conversion from a <see cref="TimeOnly"/> object to a <see cref="TimeSpan"/> which can be handled by the database engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
public class TimeOnlyConverter : ValueConverter<TimeOnly, TimeSpan>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimeOnlyConverter"/> class.
|
||||
/// </summary>
|
||||
public TimeOnlyConverter()
|
||||
: base(
|
||||
t => t.ToTimeSpan(),
|
||||
t => TimeOnly.FromTimeSpan(t)
|
||||
)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace System
|
||||
{
|
||||
/// <summary>
|
||||
/// A DatabaseProvider specific exception.
|
||||
/// </summary>
|
||||
public class DatabaseProviderException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseProviderException"/> class.
|
||||
/// </summary>
|
||||
public DatabaseProviderException()
|
||||
: base()
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseProviderException"/> class
|
||||
/// with a specified error message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message that describes the error.</param>
|
||||
public DatabaseProviderException(string message)
|
||||
: base(message)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseProviderException"/> class
|
||||
/// with a specified error message and a reference to the inner exception that is the cause of this exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message that explains the reason for the exception.</param>
|
||||
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
|
||||
public DatabaseProviderException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{ }
|
||||
|
||||
#if !NET8_0_OR_GREATER
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseProviderException"/> class with serialized data.
|
||||
/// </summary>
|
||||
/// <param name="info">The <see cref="Runtime.Serialization.SerializationInfo"/> that holds the serialized object data about the exception being thrown.</param>
|
||||
/// <param name="context">The <see cref="Runtime.Serialization.StreamingContext"/> that contains contextual information about the source or destination.</param>
|
||||
/// <exception cref="ArgumentNullException">The info parameter is null.</exception>
|
||||
/// <exception cref="Runtime.Serialization.SerializationException">The class name is null or <see cref="Exception.HResult"/> is zero (0).</exception>
|
||||
protected DatabaseProviderException(Runtime.Serialization.SerializationInfo info, Runtime.Serialization.StreamingContext context)
|
||||
: base(info, context)
|
||||
{ }
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="DatabaseFacade"/>.
|
||||
/// </summary>
|
||||
#if NET8_0_OR_GREATER
|
||||
public static partial class DatabaseFacadeExtensions
|
||||
#else
|
||||
public static class DatabaseFacadeExtensions
|
||||
#endif
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies migration files to the database.
|
||||
/// </summary>
|
||||
/// <param name="database">The database connection.</param>
|
||||
/// <param name="optionsAction">An action to set additional options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns><see langword="true"/> on success, otherwise false or an exception is thrown.</returns>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2208")]
|
||||
public static async Task<bool> ApplyMigrationsAsync(this DatabaseFacade database, Action<DatabaseMigrationOptions> optionsAction, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (database == null)
|
||||
throw new ArgumentNullException(nameof(database));
|
||||
|
||||
if (database.GetProviderType() == DatabaseProvider.InMemory)
|
||||
return true;
|
||||
|
||||
var options = new DatabaseMigrationOptions();
|
||||
optionsAction?.Invoke(options);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.MigrationsTableName))
|
||||
throw new ArgumentNullException(nameof(options.MigrationsTableName), $"The property {nameof(options.MigrationsTableName)} of the {nameof(options)} parameter is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Path))
|
||||
throw new ArgumentNullException(nameof(options.Path), $"The property {nameof(options.Path)} of the {nameof(options)} parameter is required.");
|
||||
|
||||
await database.WaitAvailableAsync(opts =>
|
||||
{
|
||||
opts.WaitDelay = options.WaitDelay;
|
||||
opts.Logger = options.Logger;
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var connection = database.GetDbConnection();
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await connection.CreateMigrationsTable(options, cancellationToken).ConfigureAwait(false))
|
||||
return false;
|
||||
|
||||
return await connection.Migrate(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the database connection is available.
|
||||
/// </summary>
|
||||
/// <param name="database">The database connection.</param>
|
||||
/// <param name="optionsAction">An action to set additional options.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>An awaitable task to wait until the database is available.</returns>
|
||||
public static async Task WaitAvailableAsync(this DatabaseFacade database, Action<DatabaseMigrationOptions> optionsAction = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (database == null)
|
||||
throw new ArgumentNullException(nameof(database));
|
||||
|
||||
if (database.GetProviderType() == DatabaseProvider.InMemory)
|
||||
return;
|
||||
|
||||
var options = new DatabaseMigrationOptions();
|
||||
optionsAction?.Invoke(options);
|
||||
|
||||
options.Logger?.LogInformation("Waiting for a database connection");
|
||||
var connection = database.GetDbConnection();
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
options.Logger?.LogInformation("Database connection available");
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep things quiet
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.WaitDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep things quiet
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static DatabaseProvider GetProviderType(this DatabaseFacade database)
|
||||
=> GetProviderType(database.ProviderName);
|
||||
|
||||
private static DatabaseProvider GetProviderType(this DbConnection connection)
|
||||
=> GetProviderType(connection.GetType().FullName);
|
||||
|
||||
private static DatabaseProvider GetProviderType(string provider)
|
||||
{
|
||||
if (provider.Contains("mysql", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.MySQL;
|
||||
if (provider.Contains("oracle", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.Oracle;
|
||||
if (provider.Contains("npgsql", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.PostgreSQL;
|
||||
if (provider.Contains("sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.SQLite;
|
||||
if (provider.Contains("sqlclient", StringComparison.OrdinalIgnoreCase)
|
||||
|| provider.Contains("sqlserver", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.SQLServer;
|
||||
if (provider.Contains("inmemory", StringComparison.OrdinalIgnoreCase))
|
||||
return DatabaseProvider.InMemory;
|
||||
|
||||
throw new DatabaseProviderException($"The database provider '{provider}' is unknown");
|
||||
}
|
||||
|
||||
private static async Task<bool> CreateMigrationsTable(this DbConnection connection, DatabaseMigrationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
|
||||
#pragma warning disable CS8509 // ignore missing cases
|
||||
command.CommandText = connection.GetProviderType() switch
|
||||
#pragma warning restore CS8509 // ignore missing cases
|
||||
{
|
||||
DatabaseProvider.MySQL => $@"CREATE TABLE IF NOT EXISTS `{options.MigrationsTableName}` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`schema_file` VARCHAR(250) NOT NULL,
|
||||
`installed_at` VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
);",
|
||||
DatabaseProvider.Oracle => $@"DECLARE ncount NUMBER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO ncount FROM dba_tables WHERE table_name = '{options.MigrationsTableName}';
|
||||
IF (ncount <= 0)
|
||||
THEN
|
||||
EXECUTE IMMEDIATE 'CREATE TABLE ""{options.MigrationsTableName}"" (
|
||||
""id"" NUMBER GENERATED by default on null as IDENTITY,
|
||||
""schema_file"" VARCHAR2(250) NOT NULL,
|
||||
""installed_at"" VARCHAR2(16) NOT NULL,
|
||||
PRIMARY KEY (""id""),
|
||||
CONSTRAINT uq_schema_file UNIQUE (""schema_file"")
|
||||
)';
|
||||
END IF;
|
||||
END;",
|
||||
DatabaseProvider.PostgreSQL => $@"CREATE TABLE IF NOT EXISTS ""{options.MigrationsTableName}"" (
|
||||
""id"" SERIAL4 PRIMARY KEY,
|
||||
""schema_file"" VARCHAR(250) NOT NULL,
|
||||
""installed_at"" VARCHAR(16) NOT NULL,
|
||||
CONSTRAINT ""uq_schema_file"" UNIQUE (""schema_file"")
|
||||
);",
|
||||
DatabaseProvider.SQLite => $@"CREATE TABLE IF NOT EXISTS ""{options.MigrationsTableName}"" (
|
||||
""id"" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
""schema_file"" TEXT(250) NOT NULL,
|
||||
""installed_at"" TEXT(16) NOT NULL,
|
||||
CONSTRAINT ""uq_schema_file"" UNIQUE (""schema_file"")
|
||||
);",
|
||||
DatabaseProvider.SQLServer => $@"IF NOT EXISTS (SELECT * FROM [sysobjects] WHERE [name] = '{options.MigrationsTableName}' AND [xtype] = 'U')
|
||||
BEGIN
|
||||
CREATE TABLE [{options.MigrationsTableName}] (
|
||||
[id] int IDENTITY(1,1) NOT NULL PRIMARY KEY,
|
||||
[schema_file] varchar(250) NOT NULL,
|
||||
[installed_at] varchar(16) NOT NULL,
|
||||
CONSTRAINT uq_schema_file UNIQUE (schema_file)
|
||||
)
|
||||
END;"
|
||||
};
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
options.Logger?.LogCritical(ex, $"Creating migrations table '{options.MigrationsTableName}' failed: {ex.InnerException?.Message ?? ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> Migrate(this DbConnection connection, DatabaseMigrationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<string> availableMigrationFiles;
|
||||
if (options.SourceAssembly == null)
|
||||
{
|
||||
availableMigrationFiles = Directory.GetFiles(options.Path)
|
||||
.Where(f => f.StartsWith(options.Path, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(f => f.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
availableMigrationFiles = options.SourceAssembly
|
||||
.GetManifestResourceNames()
|
||||
.Where(f => f.StartsWith(options.Path, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(f => f.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (availableMigrationFiles.Count == 0)
|
||||
return true;
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
|
||||
var migratedFiles = new List<string>();
|
||||
command.CommandText = connection.GetProviderType() switch
|
||||
{
|
||||
DatabaseProvider.MySQL => $"SELECT `schema_file` FROM `{options.MigrationsTableName}`;",
|
||||
DatabaseProvider.SQLServer => $"SELECT [schema_file] FROM [{options.MigrationsTableName}];",
|
||||
_ => $@"SELECT ""schema_file"" FROM ""{options.MigrationsTableName}"";",
|
||||
};
|
||||
using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
migratedFiles.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
int pathLength = options.Path.Length + 1;
|
||||
foreach (string migrationFile in availableMigrationFiles)
|
||||
{
|
||||
// remove path including the separator
|
||||
string fileName = migrationFile.Replace(options.Path, "")[1..];
|
||||
using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// max length in the database: 250 chars
|
||||
string trimmedFileName = fileName;
|
||||
if (trimmedFileName.Length > 250)
|
||||
fileName = fileName[..250];
|
||||
|
||||
if (migratedFiles.Contains(trimmedFileName))
|
||||
{
|
||||
options.Logger?.LogDebug($" Migrating file '{fileName}' done");
|
||||
continue;
|
||||
}
|
||||
|
||||
string sqlScript = null;
|
||||
if (options.SourceAssembly == null)
|
||||
{
|
||||
sqlScript = await File.ReadAllTextAsync(migrationFile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var stream = options.SourceAssembly.GetManifestResourceStream(migrationFile);
|
||||
using var sr = new StreamReader(stream);
|
||||
#if NET8_0_OR_GREATER
|
||||
sqlScript = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
sqlScript = await sr.ReadToEndAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sqlScript))
|
||||
continue;
|
||||
|
||||
options.Logger?.LogDebug($" Migrating file '{fileName}' started");
|
||||
command.Transaction = transaction;
|
||||
|
||||
await command.ExecuteScript(sqlScript, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
command.CommandText = connection.GetProviderType() switch
|
||||
{
|
||||
DatabaseProvider.MySQL => $"INSERT INTO `{options.MigrationsTableName}` (`schema_file`, `installed_at`) VALUES ('{trimmedFileName.Replace("'", "\\'")}', '{DateTime.UtcNow:yyyy-MM-dd HH:mm}');",
|
||||
DatabaseProvider.SQLServer => $"INSERT INTO [{options.MigrationsTableName}] ([schema_file], [installed_at]) VALUES ('{trimmedFileName.Replace("'", "\\'")}', '{DateTime.UtcNow:yyyy-MM-dd HH:mm}');",
|
||||
_ => $@"INSERT INTO ""{options.MigrationsTableName}"" (""schema_file"", ""installed_at"") VALUES ('{trimmedFileName.Replace("'", "\\'")}', '{DateTime.UtcNow:yyyy-MM-dd HH:mm}');",
|
||||
};
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
command.Transaction = null;
|
||||
options.Logger?.LogDebug($" Migrating file '{fileName}' successful");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
options.Logger?.LogError($"Migrating file '{fileName}' failed: {ex.InnerException?.Message ?? ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
options.Logger?.LogCritical(ex, $"Migrating the database failed ({ex.GetType().Name}): {ex.InnerException?.Message ?? ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteScript(this DbCommand command, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.Connection.GetProviderType() == DatabaseProvider.Oracle)
|
||||
{
|
||||
int affectedRows = 0;
|
||||
// Split script by a single slash in a line
|
||||
#if NET8_0_OR_GREATER
|
||||
string[] parts = FindSingleSlashInLine().Split(text);
|
||||
#else
|
||||
string[] parts = Regex.Split(text, @"\r?\n[ \t]*/[ \t]*\r?\n");
|
||||
#endif
|
||||
foreach (string part in parts)
|
||||
{
|
||||
// Make writable copy
|
||||
string pt = part;
|
||||
|
||||
// Remove the trailing semicolon from commands where they're not supported
|
||||
// (Oracle doesn't like semicolons. To keep the semicolon, it must be directly
|
||||
// preceeded by "end".)
|
||||
#if NET8_0_OR_GREATER
|
||||
pt = FindEndCommand().Replace(pt.TrimEnd(), "");
|
||||
#else
|
||||
pt = Regex.Replace(pt, @"(?<!end);$", "", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
#endif
|
||||
|
||||
// Execute all non-empty parts as individual commands
|
||||
if (!string.IsNullOrWhiteSpace(pt))
|
||||
{
|
||||
command.CommandText = pt;
|
||||
affectedRows += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
return affectedRows;
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = text;
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal enum DatabaseProvider
|
||||
{
|
||||
MySQL = 1,
|
||||
Oracle = 2,
|
||||
PostgreSQL = 3,
|
||||
SQLite = 4,
|
||||
SQLServer = 5,
|
||||
InMemory = 6,
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
[GeneratedRegex(@"\r?\n[ \t]*/[ \t]*\r?\n")]
|
||||
private static partial Regex FindSingleSlashInLine();
|
||||
|
||||
[GeneratedRegex(@"(?<!end);$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex FindEndCommand();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="DbContext"/>.
|
||||
/// </summary>
|
||||
public static class DbContextExensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a new transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see href="https://aka.ms/efcore-docs-transactions">Transactions in EF Core</see> for more information.
|
||||
/// </remarks>
|
||||
/// <param name="dbContext">The current <see cref="DbContext"/>.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="IDbContextTransaction" /> that represents the started transaction.
|
||||
/// </returns>
|
||||
public static IDbContextTransaction BeginTransaction(this DbContext dbContext)
|
||||
{
|
||||
if (dbContext.Database.GetProviderType() == DatabaseFacadeExtensions.DatabaseProvider.InMemory)
|
||||
return new DbContextTransactionStub();
|
||||
|
||||
return dbContext.Database.BeginTransaction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously starts a new transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This
|
||||
/// includes both parallel execution of async queries and any explicit concurrent use from multiple threads.
|
||||
/// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute
|
||||
/// in parallel. See <see href="https://aka.ms/efcore-docs-threading">Avoiding DbContext threading issues</see>
|
||||
/// for more information.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <see href="https://aka.ms/efcore-docs-transactions">Transactions in EF Core</see> for more information.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="dbContext">The current <see cref="DbContext"/>.</param>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken" /> to observe while waiting for the task to complete.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous transaction initialization. The task result contains a <see cref="IDbContextTransaction" /> that represents the started transaction.
|
||||
/// </returns>
|
||||
/// <exception cref="OperationCanceledException">If the <see cref="CancellationToken" /> is canceled.</exception>
|
||||
public static Task<IDbContextTransaction> BeginTransactionAsync(this DbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
if (dbContext.Database.GetProviderType() == DatabaseFacadeExtensions.DatabaseProvider.InMemory)
|
||||
return Task.FromResult<IDbContextTransaction>(new DbContextTransactionStub());
|
||||
|
||||
return dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IDbContextTransaction" />
|
||||
private class DbContextTransactionStub : IDbContextTransaction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid TransactionId { get; private set; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Commit()
|
||||
{ }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{ }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
=> new(Task.CompletedTask);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Rollback()
|
||||
{ }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RollbackAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Extends the <see cref="DbContextOptionsBuilder"/> to use a configurable database provider.
|
||||
/// </summary>
|
||||
public static class DbContextOptionsBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the supported database provider to the context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The configuration provided requires the following entries:
|
||||
/// <list type="bullet">
|
||||
/// <item><strong>Provider</strong>: MySQL | Oracle | PostgreSQL | SQLite | SQLServer</item>
|
||||
/// <item><strong>Host</strong>: hostname or IP address</item>
|
||||
/// <item><strong>Port</strong>: port number</item>
|
||||
/// <item><strong>Name</strong>: database name</item>
|
||||
/// <item><strong>Schema</strong>: schema or search path (e.g. PostgreSQL: public)</item>
|
||||
/// <item><strong>Username</strong>: username credential on the database</item>
|
||||
/// <item><strong>Password</strong>: password credential on the database</item>
|
||||
/// <item><strong>File</strong>: file name / path (for SQLite)</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="optionsBuilder">The options builder.</param>
|
||||
/// <param name="configuration">The application configuration section for the database.</param>
|
||||
/// <param name="optionsAction">An optional action to set additional options.</param>
|
||||
/// <returns>The <see cref="DbContextOptionsBuilder"/> with applied settings.</returns>
|
||||
public static DbContextOptionsBuilder UseDatabaseProvider(this DbContextOptionsBuilder optionsBuilder, IConfiguration configuration, Action<DatabaseProviderOptions> optionsAction = null)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(optionsBuilder);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
#else
|
||||
if (optionsBuilder == null)
|
||||
throw new ArgumentNullException(nameof(optionsBuilder));
|
||||
if (configuration == null)
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
#endif
|
||||
|
||||
var options = new DatabaseProviderOptions();
|
||||
optionsAction?.Invoke(options);
|
||||
|
||||
string connectionString = GetConnectionString(configuration, options);
|
||||
string provider = configuration.GetValue<string>("provider")?.ToLower();
|
||||
|
||||
var builderType = GetBuilderType(configuration);
|
||||
var extensionType = GetExtensionType(configuration);
|
||||
var actionType = typeof(Action<>).MakeGenericType(builderType);
|
||||
|
||||
object serverVersion = null;
|
||||
MethodInfo methodInfo;
|
||||
switch (provider)
|
||||
{
|
||||
case "mysql":
|
||||
methodInfo = extensionType.GetMethod("UseMySql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
if (methodInfo == null)
|
||||
methodInfo = extensionType.GetMethod("UseMySQL", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
if (methodInfo == null) // Pomelo MySQL v5
|
||||
{
|
||||
var serverVersionType = Type.GetType("Microsoft.EntityFrameworkCore.ServerVersion, Pomelo.EntityFrameworkCore.MySql");
|
||||
var autoDetectMethodInfo = serverVersionType.GetMethod("AutoDetect", new Type[] { typeof(string) });
|
||||
methodInfo = extensionType.GetMethod("UseMySql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), serverVersionType, actionType });
|
||||
serverVersion = autoDetectMethodInfo.Invoke(null, new object[] { connectionString });
|
||||
}
|
||||
break;
|
||||
case "oracle":
|
||||
methodInfo = extensionType.GetMethod("UseOracle", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
break;
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
methodInfo = extensionType.GetMethod("UseNpgsql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
break;
|
||||
case "sqlite":
|
||||
methodInfo = extensionType.GetMethod("UseSqlite", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
break;
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
methodInfo = extensionType.GetMethod("UseSqlServer", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
break;
|
||||
case "memory":
|
||||
case "inmemory":
|
||||
methodInfo = extensionType.GetMethod("UseInMemoryDatabase", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType });
|
||||
break;
|
||||
default:
|
||||
throw new DatabaseProviderException($"Unknown database provider: {provider}");
|
||||
}
|
||||
|
||||
if (serverVersion == null)
|
||||
{
|
||||
methodInfo?.Invoke(null, new object[] { optionsBuilder, connectionString, null });
|
||||
}
|
||||
else
|
||||
{
|
||||
methodInfo?.Invoke(null, new object[] { optionsBuilder, connectionString, serverVersion, null });
|
||||
}
|
||||
|
||||
return optionsBuilder;
|
||||
}
|
||||
|
||||
private static Type GetBuilderType(IConfiguration configuration)
|
||||
{
|
||||
string provider = configuration.GetValue<string>("provider")?.ToLower();
|
||||
Type builderType;
|
||||
switch (provider)
|
||||
{
|
||||
case "mysql":
|
||||
builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.MySqlDbContextOptionsBuilder, Pomelo.EntityFrameworkCore.MySql");
|
||||
if (builderType == null)
|
||||
builderType = Type.GetType("MySql.Data.EntityFrameworkCore.Infrastructure.MySQLDbContextOptionsBuilder, MySql.Data.EntityFrameworkCore");
|
||||
if (builderType == null) // as MySql.Data.EntityFrameworkCore is marked as deprecated on NuGet
|
||||
builderType = Type.GetType("MySql.EntityFrameworkCore.Infrastructure.MySQLDbContextOptionsBuilder, MySql.EntityFrameworkCore");
|
||||
break;
|
||||
case "oracle":
|
||||
builderType = Type.GetType("Oracle.EntityFrameworkCore.Infrastructure.OracleDbContextOptionsBuilder, Oracle.EntityFrameworkCore");
|
||||
break;
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
builderType = Type.GetType("Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.NpgsqlDbContextOptionsBuilder, Npgsql.EntityFrameworkCore.PostgreSQL");
|
||||
break;
|
||||
case "sqlite":
|
||||
builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.SqliteDbContextOptionsBuilder, Microsoft.EntityFrameworkCore.Sqlite");
|
||||
break;
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.SqlServerDbContextOptionsBuilder, Microsoft.EntityFrameworkCore.SqlServer");
|
||||
break;
|
||||
case "memory":
|
||||
case "inmemory":
|
||||
builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.InMemoryDbContextOptionsBuilder, Microsoft.EntityFrameworkCore.InMemory");
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown database provider: {provider}");
|
||||
}
|
||||
return builderType;
|
||||
}
|
||||
|
||||
private static Type GetExtensionType(IConfiguration configuration)
|
||||
{
|
||||
string provider = configuration.GetValue<string>("provider")?.ToLower();
|
||||
Type extensionType;
|
||||
switch (provider)
|
||||
{
|
||||
case "mysql":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.MySqlDbContextOptionsBuilderExtensions, Pomelo.EntityFrameworkCore.MySql");
|
||||
if (extensionType == null)
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.MySQLDbContextOptionsExtensions, MySql.Data.EntityFrameworkCore");
|
||||
if (extensionType == null)
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.MySQLDbContextOptionsExtensions, MySql.EntityFrameworkCore");
|
||||
break;
|
||||
case "oracle":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.OracleDbContextOptionsBuilderExtensions, Oracle.EntityFrameworkCore");
|
||||
break;
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.NpgsqlDbContextOptionsBuilderExtensions, Npgsql.EntityFrameworkCore.PostgreSQL");
|
||||
break;
|
||||
case "sqlite":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions, Microsoft.EntityFrameworkCore.Sqlite");
|
||||
break;
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.SqlServerDbContextOptionsExtensions, Microsoft.EntityFrameworkCore.SqlServer");
|
||||
break;
|
||||
case "memory":
|
||||
case "inmemory":
|
||||
extensionType = Type.GetType("Microsoft.EntityFrameworkCore.InMemoryDbContextOptionsExtensions, Microsoft.EntityFrameworkCore.InMemory");
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown database provider: {provider}");
|
||||
}
|
||||
return extensionType;
|
||||
}
|
||||
|
||||
private static string GetConnectionString(IConfiguration configuration, DatabaseProviderOptions options)
|
||||
{
|
||||
var cs = new List<string>();
|
||||
string provider = configuration.GetValue<string>("provider")?.ToLower();
|
||||
switch (provider)
|
||||
{
|
||||
case "mysql":
|
||||
cs.Add($"Server={configuration.GetValue<string>("Host")}");
|
||||
cs.Add($"Port={configuration.GetValue("Port", 3306)}");
|
||||
cs.Add($"Database={configuration.GetValue<string>("Name")}");
|
||||
cs.Add($"Uid={configuration.GetValue<string>("Username")}");
|
||||
cs.Add($"Password={configuration.GetValue<string>("Password")}");
|
||||
cs.Add($"Connection Timeout=15");
|
||||
break;
|
||||
case "oracle":
|
||||
cs.Add($"Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={configuration.GetValue<string>("Host")})(PORT={configuration.GetValue("Port", 1521)}))(CONNECT_DATA=(SERVICE_NAME={configuration.GetValue<string>("Name")})))");
|
||||
cs.Add($"User Id={configuration.GetValue<string>("Username")}");
|
||||
cs.Add($"Password={configuration.GetValue<string>("Password")}");
|
||||
cs.Add($"Connection Timeout=15");
|
||||
break;
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
cs.Add($"Server={configuration.GetValue<string>("Host")}");
|
||||
cs.Add($"Port={configuration.GetValue("Port", 5432)}");
|
||||
cs.Add($"Database={configuration.GetValue<string>("Name")}");
|
||||
cs.Add($"Search Path={configuration.GetValue("Schema", "public")}");
|
||||
cs.Add($"User Id={configuration.GetValue<string>("Username")}");
|
||||
cs.Add($"Password={configuration.GetValue<string>("Password")}");
|
||||
cs.Add($"Timeout=15");
|
||||
break;
|
||||
case "sqlite":
|
||||
string path = configuration.GetValue<string>("File");
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.AbsoluteBasePath))
|
||||
options.AbsoluteBasePath = AppContext.BaseDirectory;
|
||||
|
||||
path = Path.Combine(options.AbsoluteBasePath, path);
|
||||
}
|
||||
cs.Add($"Data Source={path}");
|
||||
cs.Add("Foreign Keys=True");
|
||||
break;
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
cs.Add($"Server={configuration.GetValue<string>("Host")},{configuration.GetValue("Port", 1433)}");
|
||||
cs.Add($"Database={configuration.GetValue<string>("Name")}");
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetValue<string>("Username")))
|
||||
{
|
||||
cs.Add($"User Id={configuration.GetValue<string>("Username")}");
|
||||
cs.Add($"Password={configuration.GetValue<string>("Password")}");
|
||||
cs.Add("Integrated Security=False");
|
||||
}
|
||||
else
|
||||
{
|
||||
cs.Add("Integrated Security=True");
|
||||
}
|
||||
cs.Add("Connect Timeout=15");
|
||||
break;
|
||||
case "memory":
|
||||
case "inmemory":
|
||||
cs.Add(configuration.GetValue("Name", provider));
|
||||
break;
|
||||
default:
|
||||
throw new DatabaseProviderException($"Unknown database provider: {provider}");
|
||||
}
|
||||
return string.Join(";", cs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using AMWD.Common.EntityFrameworkCore.Attributes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="ModelBuilder"/> of entity framework core.
|
||||
/// </summary>
|
||||
public static class ModelBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies indices and unique constraints to the properties.
|
||||
/// </summary>
|
||||
/// <param name="builder">The database model builder.</param>
|
||||
/// <returns>A reference to this instance after the operation has completed.</returns>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0019", Justification = "No pattern comparison in this case due to readability.")]
|
||||
public static ModelBuilder ApplyIndexAttributes(this ModelBuilder builder)
|
||||
{
|
||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
var indexAttribute = entityType.ClrType
|
||||
.GetProperty(property.Name)
|
||||
?.GetCustomAttribute(typeof(DatabaseIndexAttribute), false) as DatabaseIndexAttribute;
|
||||
if (indexAttribute != null)
|
||||
{
|
||||
var index = entityType.AddIndex(property);
|
||||
index.IsUnique = indexAttribute.IsUnique;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(indexAttribute.Name))
|
||||
index.SetDatabaseName(indexAttribute.Name.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts all table and column names to snake_case_names.
|
||||
/// </summary>
|
||||
/// <param name="builder">The database model builder.</param>
|
||||
/// <returns>A reference to this instance after the operation has completed.</returns>
|
||||
public static ModelBuilder ApplySnakeCase(this ModelBuilder builder)
|
||||
{
|
||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||
{
|
||||
// skip conversion when table name is explicitly set
|
||||
if ((entityType.ClrType.GetCustomAttribute(typeof(TableAttribute), false) as TableAttribute) == null)
|
||||
entityType.SetTableName(ConvertToSnakeCase(entityType.GetTableName()));
|
||||
|
||||
var identifier = StoreObjectIdentifier.Table(entityType.GetTableName(), entityType.GetSchema());
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
// skip conversion when column name is explicitly set
|
||||
if ((entityType.ClrType.GetProperty(property.Name)?.GetCustomAttribute(typeof(ColumnAttribute), false) as ColumnAttribute) == null)
|
||||
property.SetColumnName(ConvertToSnakeCase(property.GetColumnName(identifier)));
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string to its snake_case equivalent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Code borrowed from Npgsql.NameTranslation.NpgsqlSnakeCaseNameTranslator.
|
||||
/// See https://github.com/npgsql/npgsql/blob/f2b2c98f45df6d2a78eec00ae867f18944d717ca/src/Npgsql/NameTranslation/NpgsqlSnakeCaseNameTranslator.cs#L76-L136.
|
||||
/// </remarks>
|
||||
/// <param name="value">The value to convert.</param>
|
||||
private static string ConvertToSnakeCase(string value)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var state = SnakeCaseState.Start;
|
||||
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] == ' ')
|
||||
{
|
||||
if (state != SnakeCaseState.Start)
|
||||
state = SnakeCaseState.NewWord;
|
||||
}
|
||||
else if (char.IsUpper(value[i]))
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case SnakeCaseState.Upper:
|
||||
bool hasNext = i + 1 < value.Length;
|
||||
if (i > 0 && hasNext)
|
||||
{
|
||||
char nextChar = value[i + 1];
|
||||
if (!char.IsUpper(nextChar) && nextChar != '_')
|
||||
{
|
||||
sb.Append('_');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SnakeCaseState.Lower:
|
||||
case SnakeCaseState.NewWord:
|
||||
sb.Append('_');
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(char.ToLowerInvariant(value[i]));
|
||||
state = SnakeCaseState.Upper;
|
||||
}
|
||||
else if (value[i] == '_')
|
||||
{
|
||||
sb.Append('_');
|
||||
state = SnakeCaseState.Start;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (state == SnakeCaseState.NewWord)
|
||||
sb.Append('_');
|
||||
|
||||
sb.Append(value[i]);
|
||||
state = SnakeCaseState.Lower;
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private enum SnakeCaseState
|
||||
{
|
||||
Start,
|
||||
Lower,
|
||||
Upper,
|
||||
NewWord
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using AMWD.Common.EntityFrameworkCore.Converters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AMWD.Common.EntityFrameworkCore.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for the <see cref="ModelConfigurationBuilder"/> of entity framework core.
|
||||
/// </summary>
|
||||
public static class ModelConfigurationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds converters for the <see cref="DateOnly"/> datatype introduced with .NET 6.0.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ModelConfigurationBuilder"/> instance.</param>
|
||||
/// <returns>The <see cref="ModelConfigurationBuilder"/> instance after applying the converters.</returns>
|
||||
public static ModelConfigurationBuilder AddDateOnlyConverters(this ModelConfigurationBuilder builder)
|
||||
{
|
||||
builder.Properties<DateOnly>()
|
||||
.HaveConversion<DateOnlyConverter>()
|
||||
.HaveColumnType("date");
|
||||
builder.Properties<DateOnly?>()
|
||||
.HaveConversion<NullableDateOnlyConverter>()
|
||||
.HaveColumnType("date");
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds converters for the <see cref="TimeOnly"/> datatype introduced with .NET 6.0.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ModelConfigurationBuilder"/> instance.</param>
|
||||
/// <returns>The <see cref="ModelConfigurationBuilder"/> instance after applying the converters.</returns>
|
||||
public static ModelConfigurationBuilder AddTimeOnlyConverters(this ModelConfigurationBuilder builder)
|
||||
{
|
||||
builder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyConverter>()
|
||||
.HaveColumnType("time");
|
||||
builder.Properties<TimeOnly?>()
|
||||
.HaveConversion<NullableTimeOnlyConverter>()
|
||||
.HaveColumnType("time");
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: SuppressMessage("Usage", "CA2254")]
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for the database migration.
|
||||
/// </summary>
|
||||
public class DatabaseMigrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a logger of the type <see cref="ILogger"/>.
|
||||
/// </summary>
|
||||
public ILogger Logger { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the migrations table name.
|
||||
/// </summary>
|
||||
public string MigrationsTableName { get; set; } = "__migrations";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the absolute path to the migration files.
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source assembly for embedded files.
|
||||
/// </summary>
|
||||
public Assembly SourceAssembly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a delay to wait before a connect retry.
|
||||
/// </summary>
|
||||
public TimeSpan WaitDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for the database provider.
|
||||
/// </summary>
|
||||
public class DatabaseProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the absolute path to the database directory.
|
||||
/// </summary>
|
||||
public string AbsoluteBasePath { get; set; }
|
||||
}
|
||||
}
|
||||
22
src/AMWD.Common.MessagePack/AMWD.Common.MessagePack.csproj
Normal file
22
src/AMWD.Common.MessagePack/AMWD.Common.MessagePack.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks>
|
||||
|
||||
<AssemblyName>amwd-common-msgpack</AssemblyName>
|
||||
<RootNamespace>AMWD.Common.MessagePack</RootNamespace>
|
||||
|
||||
<NrtTagMatch>msgpack/v[0-9]*</NrtTagMatch>
|
||||
<PackageId>AMWD.Common.MessagePack</PackageId>
|
||||
<Product>AM.WD Common Library for MessagePack</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="2.5.168" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net8.0'">
|
||||
<PackageReference Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using AMWD.Common.MessagePack.Utilities;
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPAddress"/> array to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPAddressArrayFormatter : IMessagePackFormatter<IPAddress[]>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IPAddress[] Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return null;
|
||||
|
||||
int bytePos = 0;
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
byte[] buffer = bytes.Skip(bytePos).Take(sizeof(int)).ToArray();
|
||||
bytePos += sizeof(int);
|
||||
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
int length = BitConverter.ToInt32(buffer, 0);
|
||||
|
||||
int arrayPos = 0;
|
||||
var array = new IPAddress[length];
|
||||
while (bytePos < bytes.Length)
|
||||
{
|
||||
byte len = bytes.Skip(bytePos).First();
|
||||
bytePos++;
|
||||
|
||||
buffer = bytes.Skip(bytePos).Take(len).ToArray();
|
||||
bytePos += len;
|
||||
|
||||
array[arrayPos] = new IPAddress(buffer);
|
||||
arrayPos++;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, IPAddress[] value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = new List<byte>();
|
||||
|
||||
int length = value.Length;
|
||||
byte[] buffer = BitConverter.GetBytes(length);
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
bytes.AddRange(buffer);
|
||||
|
||||
foreach (var ip in value)
|
||||
{
|
||||
buffer = ip.GetAddressBytes();
|
||||
bytes.Add((byte)buffer.Length);
|
||||
bytes.AddRange(buffer);
|
||||
}
|
||||
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, [.. bytes], options);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/AMWD.Common.MessagePack/Formatters/IPAddressFormatter.cs
Normal file
34
src/AMWD.Common.MessagePack/Formatters/IPAddressFormatter.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Net;
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPAddress"/> to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPAddressFormatter : IMessagePackFormatter<IPAddress>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IPAddress Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return null;
|
||||
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, IPAddress value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] bytes = value.GetAddressBytes();
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, bytes, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using AMWD.Common.MessagePack.Utilities;
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPAddress"/> list to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPAddressListFormatter : IMessagePackFormatter<List<IPAddress>>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public List<IPAddress> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return null;
|
||||
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
|
||||
// skipping the length information
|
||||
int bytePos = sizeof(int);
|
||||
|
||||
var list = new List<IPAddress>();
|
||||
while (bytePos < bytes.Length)
|
||||
{
|
||||
byte len = bytes.Skip(bytePos).First();
|
||||
bytePos++;
|
||||
|
||||
byte[] buffer = bytes.Skip(bytePos).Take(len).ToArray();
|
||||
bytePos += len;
|
||||
|
||||
list.Add(new IPAddress(buffer));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, List<IPAddress> value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = new List<byte>();
|
||||
|
||||
int length = value.Count;
|
||||
byte[] buffer = BitConverter.GetBytes(length);
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
bytes.AddRange(buffer);
|
||||
|
||||
foreach (var ip in value)
|
||||
{
|
||||
buffer = ip.GetAddressBytes();
|
||||
bytes.Add((byte)buffer.Length);
|
||||
bytes.AddRange(buffer);
|
||||
}
|
||||
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, [.. bytes], options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AMWD.Common.MessagePack.Utilities;
|
||||
#if NET8_0_OR_GREATER
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
#else
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
#endif
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPNetwork"/> array to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPNetworkArrayFormatter : IMessagePackFormatter<IPNetwork[]>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IPNetwork[] Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return null;
|
||||
|
||||
int bytePos = 0;
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
byte[] buffer = bytes.Skip(bytePos).Take(sizeof(int)).ToArray();
|
||||
bytePos += sizeof(int);
|
||||
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
int length = BitConverter.ToInt32(buffer, 0);
|
||||
|
||||
int arrayPos = 0;
|
||||
var array = new IPNetwork[length];
|
||||
while (bytePos < bytes.Length)
|
||||
{
|
||||
byte len = bytes.Skip(bytePos).First();
|
||||
bytePos++;
|
||||
|
||||
buffer = bytes.Skip(bytePos).Take(len).ToArray();
|
||||
bytePos += len;
|
||||
|
||||
array[arrayPos] = IPNetworkFormatter.DeserializeInternal(buffer);
|
||||
arrayPos++;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, IPNetwork[] value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = new List<byte>();
|
||||
|
||||
int length = value.Length;
|
||||
byte[] buffer = BitConverter.GetBytes(length);
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
bytes.AddRange(buffer);
|
||||
|
||||
foreach (var network in value)
|
||||
{
|
||||
buffer = IPNetworkFormatter.SerializeInternal(network);
|
||||
bytes.Add((byte)buffer.Length);
|
||||
bytes.AddRange(buffer);
|
||||
}
|
||||
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, bytes.ToArray(), options);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/AMWD.Common.MessagePack/Formatters/IPNetworkFormatter.cs
Normal file
68
src/AMWD.Common.MessagePack/Formatters/IPNetworkFormatter.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
#if NET8_0_OR_GREATER
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
#else
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
#endif
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPNetwork"/> to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPNetworkFormatter : IMessagePackFormatter<IPNetwork>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IPNetwork Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return default;
|
||||
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
return DeserializeInternal(bytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, IPNetwork value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == default)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] bytes = SerializeInternal(value);
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, bytes, options);
|
||||
}
|
||||
|
||||
internal static byte[] SerializeInternal(IPNetwork network)
|
||||
{
|
||||
// IP network prefix has a maximum of 128 bit - therefore the length can be covered with a byte.
|
||||
byte prefixLength = (byte)network.PrefixLength;
|
||||
#if NET8_0_OR_GREATER
|
||||
byte[] prefixBytes = network.BaseAddress.GetAddressBytes();
|
||||
#else
|
||||
byte[] prefixBytes = network.Prefix.GetAddressBytes();
|
||||
#endif
|
||||
|
||||
byte[] bytes = new byte[prefixBytes.Length + 1];
|
||||
bytes[0] = prefixLength;
|
||||
Array.Copy(prefixBytes, 0, bytes, 1, prefixBytes.Length);
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
internal static IPNetwork DeserializeInternal(byte[] bytes)
|
||||
{
|
||||
byte prefixLength = bytes[0];
|
||||
byte[] prefixBytes = bytes.Skip(1).ToArray();
|
||||
|
||||
var prefix = new IPAddress(prefixBytes);
|
||||
return new IPNetwork(prefix, prefixLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AMWD.Common.MessagePack.Utilities;
|
||||
#if NET8_0_OR_GREATER
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
#else
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
#endif
|
||||
|
||||
namespace MessagePack.Formatters
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialization of an <see cref="IPNetwork"/> list to and from <see cref="MessagePack"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPNetworkListFormatter : IMessagePackFormatter<List<IPNetwork>>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public List<IPNetwork> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil)
|
||||
return null;
|
||||
|
||||
byte[] bytes = options.Resolver.GetFormatterWithVerify<byte[]>().Deserialize(ref reader, options);
|
||||
|
||||
// skipping the length information
|
||||
int bytePos = sizeof(int);
|
||||
|
||||
var list = new List<IPNetwork>();
|
||||
while (bytePos < bytes.Length)
|
||||
{
|
||||
byte len = bytes.Skip(bytePos).First();
|
||||
bytePos++;
|
||||
|
||||
byte[] buffer = bytes.Skip(bytePos).Take(len).ToArray();
|
||||
bytePos += len;
|
||||
|
||||
list.Add(IPNetworkFormatter.DeserializeInternal(buffer));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Serialize(ref MessagePackWriter writer, List<IPNetwork> value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = new List<byte>();
|
||||
|
||||
int length = value.Count;
|
||||
byte[] buffer = BitConverter.GetBytes(length);
|
||||
NetworkHelper.SwapBigEndian(buffer);
|
||||
bytes.AddRange(buffer);
|
||||
|
||||
foreach (var network in value)
|
||||
{
|
||||
buffer = IPNetworkFormatter.SerializeInternal(network);
|
||||
bytes.Add((byte)buffer.Length);
|
||||
bytes.AddRange(buffer);
|
||||
}
|
||||
|
||||
options.Resolver.GetFormatterWithVerify<byte[]>().Serialize(ref writer, bytes.ToArray(), options);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/AMWD.Common.MessagePack/Utilities/NetworkHelper.cs
Normal file
17
src/AMWD.Common.MessagePack/Utilities/NetworkHelper.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace AMWD.Common.MessagePack.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides some network utils.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal static class NetworkHelper
|
||||
{
|
||||
public static void SwapBigEndian(byte[] array)
|
||||
{
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(array);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/AMWD.Common.Test/AMWD.Common.Test.csproj
Normal file
19
src/AMWD.Common.Test/AMWD.Common.Test.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
||||
<AssemblyName>amwd-common-test</AssemblyName>
|
||||
<RootNamespace>AMWD.Common.Test</RootNamespace>
|
||||
|
||||
<NrtTagMatch>test/v[0-9]*</NrtTagMatch>
|
||||
<PackageId>AMWD.Common.Test</PackageId>
|
||||
<Product>AM.WD Common Library for Unit-Testing</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.4.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
130
src/AMWD.Common.Test/HttpMessageHandlerMoq.cs
Normal file
130
src/AMWD.Common.Test/HttpMessageHandlerMoq.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace AMWD.Common.Test
|
||||
{
|
||||
/// <summary>
|
||||
/// Wrapps the <see cref="Mock{HttpMessageHandler}"/> including the setup.
|
||||
/// </summary>
|
||||
public class HttpMessageHandlerMoq
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpMessageHandlerMoq"/> class.
|
||||
/// </summary>
|
||||
public HttpMessageHandlerMoq()
|
||||
{
|
||||
Response = new() { StatusCode = HttpStatusCode.OK };
|
||||
Callbacks = new();
|
||||
|
||||
Mock = new();
|
||||
Mock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
{
|
||||
var callback = new HttpMessageRequestCallback
|
||||
{
|
||||
Headers = req.Headers,
|
||||
Method = req.Method,
|
||||
Properties = req.Properties,
|
||||
RequestUri = req.RequestUri,
|
||||
Version = req.Version
|
||||
};
|
||||
|
||||
if (req.Content != null)
|
||||
{
|
||||
callback.ContentBytes = await req.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||
callback.ContentString = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Callbacks.Add(callback);
|
||||
})
|
||||
.ReturnsAsync(Response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mocked <see cref="HttpMessageHandler"/>.
|
||||
/// </summary>
|
||||
public Mock<HttpMessageHandler> Mock { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the placed request.
|
||||
/// </summary>
|
||||
public List<HttpMessageRequestCallback> Callbacks { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP response, that should be "sent".
|
||||
/// </summary>
|
||||
public HttpResponseMessage Response { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disposes and resets the <see cref="Response"/> and <see cref="Callbacks"/>.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
Response.Dispose();
|
||||
Response = new() { StatusCode = HttpStatusCode.OK };
|
||||
|
||||
Callbacks.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the number of calls to the HTTP request.
|
||||
/// </summary>
|
||||
/// <param name="times"></param>
|
||||
public void Verify(Times times)
|
||||
=> Mock.Protected().Verify("SendAsync", times, ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
/// <summary>
|
||||
/// Represents the placed HTTP request.
|
||||
/// </summary>
|
||||
public class HttpMessageRequestCallback
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the contents of the HTTP message.
|
||||
/// </summary>
|
||||
public byte[] ContentBytes { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the contents of the HTTP message.
|
||||
/// </summary>
|
||||
public string ContentString { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of HTTP request headers.
|
||||
/// </summary>
|
||||
public HttpRequestHeaders Headers { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP method used by the HTTP request message.
|
||||
/// </summary>
|
||||
public HttpMethod Method { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets of properties for the HTTP request.
|
||||
/// </summary>
|
||||
public IDictionary<string, object> Properties { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Uri"/> used for the HTTP request.
|
||||
/// </summary>
|
||||
public Uri RequestUri { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="RequestUri"/> string representation.
|
||||
/// </summary>
|
||||
public string RequestUrl => RequestUri?.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP message version.
|
||||
/// </summary>
|
||||
public Version Version { get; internal set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/AMWD.Common.Test/SnapshotAssert.cs
Normal file
87
src/AMWD.Common.Test/SnapshotAssert.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AMWD.Common.Test
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a snapshot comparison for content aggregation (e.g. files).
|
||||
/// </summary>
|
||||
public sealed class SnapshotAssert
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests whether the specified string is equal to the saved snapshot.
|
||||
/// </summary>
|
||||
/// <param name="actual">The current aggregated content string.</param>
|
||||
/// <param name="message">An error message.</param>
|
||||
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
|
||||
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
|
||||
public static void AreEqual(string actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
|
||||
{
|
||||
string cleanLineEnding = actual
|
||||
.Replace("\r\n", "\n") // Windows
|
||||
.Replace("\r", "\n"); // old MacOS
|
||||
AreEqual(Encoding.UTF8.GetBytes(cleanLineEnding), message, callerFilePath, callerMemberName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether the specified byte array is equal to the saved snapshot.
|
||||
/// </summary>
|
||||
/// <param name="actual">The current aggregated content bytes.</param>
|
||||
/// <param name="message">An error message.</param>
|
||||
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
|
||||
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
|
||||
public static void AreEqual(byte[] actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
|
||||
=> AreEqual(actual, 0, -1, message, callerFilePath, callerMemberName);
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether the specified byte array is equal to the saved snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The past has shown, that e.g. wkhtmltopdf prints the current timestamp at the beginning of the PDF file.
|
||||
/// Therefore only a specific part of that file can be asserted to be equal.
|
||||
/// </remarks>
|
||||
/// <param name="actual">The current aggregated content bytes.</param>
|
||||
/// <param name="firstByteIndex">The first byte to compare.</param>
|
||||
/// <param name="lastByteIndex">The last byte to compare.</param>
|
||||
/// <param name="message">An error message.</param>
|
||||
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
|
||||
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
|
||||
public static void AreEqual(byte[] actual, int firstByteIndex, int lastByteIndex, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
|
||||
{
|
||||
string callerDir = Path.GetDirectoryName(callerFilePath);
|
||||
string callerFile = Path.GetFileNameWithoutExtension(callerFilePath);
|
||||
|
||||
string snapshotDir = Path.Combine(callerDir, "Snapshots", callerFile);
|
||||
string snapshotFile = Path.Combine(snapshotDir, $"{callerMemberName}.snap");
|
||||
|
||||
if (File.Exists(snapshotFile))
|
||||
{
|
||||
byte[] expected = File.ReadAllBytes(snapshotFile);
|
||||
|
||||
var actualBytes = actual.Skip(firstByteIndex);
|
||||
var expectedBytes = expected.Skip(firstByteIndex);
|
||||
|
||||
if (lastByteIndex > firstByteIndex)
|
||||
{
|
||||
actualBytes = actualBytes.Take(lastByteIndex - firstByteIndex);
|
||||
expectedBytes = expectedBytes.Take(lastByteIndex - firstByteIndex);
|
||||
}
|
||||
|
||||
if (message == null)
|
||||
CollectionAssert.AreEqual(expectedBytes.ToArray(), actualBytes.ToArray());
|
||||
else
|
||||
CollectionAssert.AreEqual(expectedBytes.ToArray(), actualBytes.ToArray(), message);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!Directory.Exists(snapshotDir))
|
||||
Directory.CreateDirectory(snapshotDir);
|
||||
|
||||
File.WriteAllBytes(snapshotFile, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/AMWD.Common.Test/TcpClientMoq.cs
Normal file
175
src/AMWD.Common.Test/TcpClientMoq.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace AMWD.Common.Test
|
||||
{
|
||||
/// <summary>
|
||||
/// Wrapps the <see cref="Mock{TcpClient}"/> including the setup.
|
||||
/// </summary>
|
||||
public class TcpClientMoq
|
||||
{
|
||||
private readonly Mock<NetworkStream> _streamMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TcpClientMoq"/> class.
|
||||
/// </summary>
|
||||
public TcpClientMoq()
|
||||
{
|
||||
Callbacks = new();
|
||||
Response = new byte[0];
|
||||
|
||||
_streamMock = new();
|
||||
_streamMock
|
||||
.Setup(s => s.WriteAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<byte[], int, int, CancellationToken>((buffer, offset, count, _) =>
|
||||
{
|
||||
var callback = new TcpClientCallback
|
||||
{
|
||||
Buffer = new byte[count],
|
||||
Offset = offset,
|
||||
Count = count,
|
||||
Type = TcpClientCallback.WriteType.Asynchronous
|
||||
};
|
||||
Array.Copy(buffer, offset, callback.Buffer, 0, count);
|
||||
|
||||
Callbacks.Add(callback);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
_streamMock
|
||||
.Setup(s => s.Write(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Callback<byte[], int, int>((buffer, offset, count) =>
|
||||
{
|
||||
var callback = new TcpClientCallback
|
||||
{
|
||||
Buffer = new byte[count],
|
||||
Offset = offset,
|
||||
Count = count,
|
||||
Type = TcpClientCallback.WriteType.Synchronous
|
||||
};
|
||||
Array.Copy(buffer, offset, callback.Buffer, 0, count);
|
||||
|
||||
Callbacks.Add(callback);
|
||||
});
|
||||
|
||||
_streamMock
|
||||
.Setup(s => s.ReadAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<byte[], int, int, CancellationToken>((buffer, offset, count, _) =>
|
||||
{
|
||||
byte[] bytes = Response ?? new byte[0];
|
||||
Array.Copy(bytes, 0, buffer, offset, Math.Min(bytes.Length, count));
|
||||
})
|
||||
.ReturnsAsync(Response?.Length ?? 0);
|
||||
_streamMock
|
||||
.Setup(s => s.Read(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Callback<byte[], int, int>((buffer, offset, count) =>
|
||||
{
|
||||
byte[] bytes = Response ?? new byte[0];
|
||||
Array.Copy(bytes, 0, buffer, offset, Math.Min(bytes.Length, count));
|
||||
})
|
||||
.Returns(Response?.Length ?? 0);
|
||||
|
||||
Mock = new();
|
||||
Mock
|
||||
.Setup(c => c.GetStream())
|
||||
.Returns(_streamMock.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mocked <see cref="TcpClient"/>.
|
||||
/// </summary>
|
||||
public Mock<TcpClient> Mock { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the placed request.
|
||||
/// </summary>
|
||||
public List<TcpClientCallback> Callbacks { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the byte response, that should be "sent".
|
||||
/// </summary>
|
||||
public byte[] Response { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resets the <see cref="Response"/> and <see cref="Callbacks"/>.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
Response = new byte[0];
|
||||
Callbacks.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the number of calls writing asynchronous to the stream.
|
||||
/// </summary>
|
||||
/// <param name="times">Number of calls.</param>
|
||||
public void VerifyWriteAsync(Times times)
|
||||
=> _streamMock.Verify(s => s.WriteAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), times);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the number of calls writing synchronous to the stream.
|
||||
/// </summary>
|
||||
/// <param name="times">Number of calls.</param>
|
||||
public void VerifyWriteSync(Times times)
|
||||
=> _streamMock.Verify(s => s.Write(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()), times);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the number of calls reading asynchronous from the stream.
|
||||
/// </summary>
|
||||
/// <param name="times">Number of calls.</param>
|
||||
public void VerifyReadAsync(Times times)
|
||||
=> _streamMock.Verify(s => s.ReadAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), times);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the number of calls reading synchronous from the stream.
|
||||
/// </summary>
|
||||
/// <param name="times">Number of calls.</param>
|
||||
public void VerifyReadSync(Times times)
|
||||
=> _streamMock.Verify(s => s.Read(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()), times);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the placed TCP request.
|
||||
/// </summary>
|
||||
public class TcpClientCallback
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the type (a/synchronous call).
|
||||
/// </summary>
|
||||
public WriteType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the buffer content.
|
||||
/// </summary>
|
||||
public byte[] Buffer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the offset.
|
||||
/// </summary>
|
||||
public int Offset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the byte count.
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lists the possible request types.
|
||||
/// </summary>
|
||||
public enum WriteType
|
||||
{
|
||||
/// <summary>
|
||||
/// The request was synchronous.
|
||||
/// </summary>
|
||||
Synchronous = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The request was asynchronous.
|
||||
/// </summary>
|
||||
Asynchronous = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/AMWD.Common/AMWD.Common.csproj
Normal file
27
src/AMWD.Common/AMWD.Common.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
|
||||
|
||||
<AssemblyName>amwd-common</AssemblyName>
|
||||
<RootNamespace>AMWD.Common</RootNamespace>
|
||||
|
||||
<PackageId>AMWD.Common</PackageId>
|
||||
<Product>AM.WD Common Library</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Unclassified.DeepConvert" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
36
src/AMWD.Common/Cli/Argument.cs
Normal file
36
src/AMWD.Common/Cli/Argument.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace AMWD.Common.Cli
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a logical argument in the command line. Options with their additional
|
||||
/// parameters are combined in one argument.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class Argument
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="Argument"/> class.
|
||||
/// </summary>
|
||||
/// <param name="option">The <see cref="Option"/> that is set in this argument; or null.</param>
|
||||
/// <param name="values">The additional parameter values for the option; or the argument value.</param>
|
||||
internal Argument(Option option, string[] values)
|
||||
{
|
||||
Option = option;
|
||||
Values = values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Option"/> that is set in this argument; or null.
|
||||
/// </summary>
|
||||
public Option Option { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the additional parameter values for the option; or the argument value.
|
||||
/// </summary>
|
||||
public string[] Values { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first item of <see cref="Values"/>; or null.
|
||||
/// </summary>
|
||||
public string Value => Values.Length > 0 ? Values[0] : null;
|
||||
}
|
||||
}
|
||||
366
src/AMWD.Common/Cli/CommandLineParser.cs
Normal file
366
src/AMWD.Common/Cli/CommandLineParser.cs
Normal file
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AMWD.Common.Cli
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides options and arguments parsing from command line arguments or a single string.
|
||||
/// </summary>
|
||||
public class CommandLineParser
|
||||
{
|
||||
#region Private data
|
||||
|
||||
private string[] _args;
|
||||
private List<Argument> _parsedArguments;
|
||||
private readonly List<Option> _options = [];
|
||||
|
||||
#endregion Private data
|
||||
|
||||
#region Configuration properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the option names are case-sensitive.
|
||||
/// (Default: false)
|
||||
/// </summary>
|
||||
public bool IsCaseSensitive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether incomplete options can be automatically
|
||||
/// completed if there is only a single matching option.
|
||||
/// (Default: true)
|
||||
/// </summary>
|
||||
public bool AutoCompleteOptions { get; set; } = true;
|
||||
|
||||
#endregion Configuration properties
|
||||
|
||||
#region Custom arguments line parsing
|
||||
|
||||
// Source: http://stackoverflow.com/a/23961658/143684
|
||||
/// <summary>
|
||||
/// Parses a single string into an arguments array.
|
||||
/// </summary>
|
||||
/// <param name="argsString">The string that contains the entire command line.</param>
|
||||
public static string[] ParseArgsString(string argsString)
|
||||
{
|
||||
// Collects the split argument strings
|
||||
var args = new List<string>();
|
||||
|
||||
// Builds the current argument
|
||||
var currentArg = new StringBuilder();
|
||||
|
||||
// Indicates whether the last character was a backslash escape character
|
||||
bool escape = false;
|
||||
|
||||
// Indicates whether we're in a quoted range
|
||||
bool inQuote = false;
|
||||
|
||||
// Indicates whether there were quotes in the current arguments
|
||||
bool hadQuote = false;
|
||||
|
||||
// Remembers the previous character
|
||||
char prevCh = '\0';
|
||||
|
||||
// Iterate all characters from the input string
|
||||
for (int i = 0; i < argsString.Length; i++)
|
||||
{
|
||||
char ch = argsString[i];
|
||||
if (ch == '\\' && !escape)
|
||||
{
|
||||
// Beginning of a backslash-escape sequence
|
||||
escape = true;
|
||||
}
|
||||
else if (ch == '\\' && escape)
|
||||
{
|
||||
// Double backslash, keep one
|
||||
currentArg.Append(ch);
|
||||
escape = false;
|
||||
}
|
||||
else if (ch == '"' && !escape)
|
||||
{
|
||||
// Toggle quoted range
|
||||
inQuote = !inQuote;
|
||||
hadQuote = true;
|
||||
if (inQuote && prevCh == '"')
|
||||
{
|
||||
// Doubled quote within a quoted range is like escaping
|
||||
currentArg.Append(ch);
|
||||
}
|
||||
}
|
||||
else if (ch == '"' && escape)
|
||||
{
|
||||
// Backslash-escaped quote, keep it
|
||||
currentArg.Append(ch);
|
||||
escape = false;
|
||||
}
|
||||
else if (char.IsWhiteSpace(ch) && !inQuote)
|
||||
{
|
||||
if (escape)
|
||||
{
|
||||
// Add pending escape char
|
||||
currentArg.Append('\\');
|
||||
escape = false;
|
||||
}
|
||||
// Accept empty arguments only if they are quoted
|
||||
if (currentArg.Length > 0 || hadQuote)
|
||||
{
|
||||
args.Add(currentArg.ToString());
|
||||
}
|
||||
// Reset for next argument
|
||||
currentArg.Clear();
|
||||
hadQuote = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (escape)
|
||||
{
|
||||
// Add pending escape char
|
||||
currentArg.Append('\\');
|
||||
escape = false;
|
||||
}
|
||||
// Copy character from input, no special meaning
|
||||
currentArg.Append(ch);
|
||||
}
|
||||
prevCh = ch;
|
||||
}
|
||||
// Save last argument
|
||||
if (currentArg.Length > 0 || hadQuote)
|
||||
{
|
||||
args.Add(currentArg.ToString());
|
||||
}
|
||||
return [.. args];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the command line arguments from a single string.
|
||||
/// </summary>
|
||||
/// <param name="argsString">The string that contains the entire command line.</param>
|
||||
public void ReadArgs(string argsString)
|
||||
{
|
||||
_args = ParseArgsString(argsString);
|
||||
}
|
||||
|
||||
#endregion Custom arguments line parsing
|
||||
|
||||
#region Options management
|
||||
|
||||
/// <summary>
|
||||
/// Registers a named option without additional parameters.
|
||||
/// </summary>
|
||||
/// <param name="name">The option name.</param>
|
||||
/// <returns>The option instance.</returns>
|
||||
public Option RegisterOption(string name)
|
||||
{
|
||||
return RegisterOption(name, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a named option.
|
||||
/// </summary>
|
||||
/// <param name="name">The option name.</param>
|
||||
/// <param name="parameterCount">The number of additional parameters for this option.</param>
|
||||
/// <returns>The option instance.</returns>
|
||||
public Option RegisterOption(string name, int parameterCount)
|
||||
{
|
||||
var option = new Option(name, parameterCount);
|
||||
_options.Add(option);
|
||||
return option;
|
||||
}
|
||||
|
||||
#endregion Options management
|
||||
|
||||
#region Parsing method
|
||||
|
||||
/// <summary>
|
||||
/// Parses all command line arguments.
|
||||
/// </summary>
|
||||
/// <param name="args">The command line arguments.</param>
|
||||
public void Parse(string[] args)
|
||||
{
|
||||
_args = args ?? throw new ArgumentNullException(nameof(args));
|
||||
Parse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses all command line arguments.
|
||||
/// </summary>
|
||||
public void Parse()
|
||||
{
|
||||
// Use args of the current process if no other source was given
|
||||
if (_args == null)
|
||||
{
|
||||
_args = Environment.GetCommandLineArgs();
|
||||
if (_args.Length > 0)
|
||||
{
|
||||
// Skip myself (args[0])
|
||||
_args = _args.Skip(1).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear/reset data
|
||||
_parsedArguments = [];
|
||||
foreach (var option in _options)
|
||||
{
|
||||
option.IsSet = false;
|
||||
option.SetCount = 0;
|
||||
option.Argument = null;
|
||||
}
|
||||
|
||||
var comparison = IsCaseSensitive
|
||||
? StringComparison.Ordinal
|
||||
: StringComparison.OrdinalIgnoreCase;
|
||||
var argumentWalker = new EnumerableWalker<string>(_args);
|
||||
bool optMode = true;
|
||||
foreach (string arg in argumentWalker.Cast<string>())
|
||||
{
|
||||
if (arg == "--")
|
||||
{
|
||||
optMode = false;
|
||||
}
|
||||
else if (optMode && (arg.StartsWith("/") || arg.StartsWith("-")))
|
||||
{
|
||||
string optName = arg.Substring(arg.StartsWith("--") ? 2 : 1);
|
||||
|
||||
// Split option value if separated with : or = instead of whitespace
|
||||
int separatorIndex = optName.IndexOfAny([':', '=']);
|
||||
string optValue = null;
|
||||
if (separatorIndex != -1)
|
||||
{
|
||||
optValue = optName.Substring(separatorIndex + 1);
|
||||
optName = optName.Substring(0, separatorIndex);
|
||||
}
|
||||
|
||||
// Find the option with complete name match
|
||||
var option = _options.FirstOrDefault(o => o.Names.Any(n => n.Equals(optName, comparison)));
|
||||
if (option == null)
|
||||
{
|
||||
// Try to complete the name to a unique registered option
|
||||
var matchingOptions = _options.Where(o => o.Names.Any(n => n.StartsWith(optName, comparison))).ToList();
|
||||
if (AutoCompleteOptions && matchingOptions.Count > 1)
|
||||
throw new Exception("Invalid option, completion is not unique: " + arg);
|
||||
|
||||
if (!AutoCompleteOptions || matchingOptions.Count == 0)
|
||||
throw new Exception("Unknown option: " + arg);
|
||||
|
||||
// Accept the single auto-completed option
|
||||
option = matchingOptions[0];
|
||||
}
|
||||
|
||||
// Check for single usage
|
||||
if (option.IsSingle && option.IsSet)
|
||||
throw new Exception("Option cannot be set multiple times: " + arg);
|
||||
|
||||
// Collect option values from next argument strings
|
||||
string[] values = new string[option.ParameterCount];
|
||||
for (int i = 0; i < option.ParameterCount; i++)
|
||||
{
|
||||
if (optValue != null)
|
||||
{
|
||||
// The first value was included in this argument string
|
||||
values[i] = optValue;
|
||||
optValue = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetch another argument string
|
||||
values[i] = argumentWalker.GetNext();
|
||||
}
|
||||
|
||||
if (values[i] == null)
|
||||
throw new Exception("Missing argument " + (i + 1) + " for option: " + arg);
|
||||
}
|
||||
var argument = new Argument(option, values);
|
||||
|
||||
// Set usage data on the option instance for quick access
|
||||
option.IsSet = true;
|
||||
option.SetCount++;
|
||||
option.Argument = argument;
|
||||
|
||||
if (option.Action != null)
|
||||
{
|
||||
option.Action(argument);
|
||||
}
|
||||
else
|
||||
{
|
||||
_parsedArguments.Add(argument);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_parsedArguments.Add(new Argument(null, [arg]));
|
||||
}
|
||||
}
|
||||
|
||||
var missingOption = _options.FirstOrDefault(o => o.IsRequired && !o.IsSet);
|
||||
if (missingOption != null)
|
||||
throw new Exception("Missing required option: /" + missingOption.Names[0]);
|
||||
}
|
||||
|
||||
#endregion Parsing method
|
||||
|
||||
#region Parsed data properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed arguments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
|
||||
/// exception handling.
|
||||
/// </remarks>
|
||||
public Argument[] Arguments
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_parsedArguments == null)
|
||||
Parse();
|
||||
|
||||
return [.. _parsedArguments];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the options that are set in the command line, including their value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
|
||||
/// exception handling.
|
||||
/// </remarks>
|
||||
public Option[] SetOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_parsedArguments == null)
|
||||
Parse();
|
||||
|
||||
return _parsedArguments
|
||||
.Where(a => a.Option != null)
|
||||
.Select(a => a.Option)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the free arguments that are set in the command line and don't belong to an option.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
|
||||
/// exception handling.
|
||||
/// </remarks>
|
||||
public string[] FreeArguments
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_parsedArguments == null)
|
||||
Parse();
|
||||
|
||||
return _parsedArguments
|
||||
.Where(a => a.Option == null)
|
||||
.Select(a => a.Value)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Parsed data properties
|
||||
}
|
||||
}
|
||||
53
src/AMWD.Common/Cli/EnumerableWalker.cs
Normal file
53
src/AMWD.Common/Cli/EnumerableWalker.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AMWD.Common.Cli
|
||||
{
|
||||
/// <summary>
|
||||
/// Walks through an <see cref="IEnumerable{T}"/> and allows retrieving additional items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <remarks>
|
||||
/// Initialises a new instance of the <see cref="EnumerableWalker{T}"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="array">The array to walk though.</param>
|
||||
internal class EnumerableWalker<T>(IEnumerable<T> array)
|
||||
: IEnumerable<T> where T : class
|
||||
{
|
||||
private readonly IEnumerable<T> _array = array ?? throw new ArgumentNullException(nameof(array));
|
||||
private IEnumerator<T> _enumerator;
|
||||
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator()
|
||||
{
|
||||
_enumerator = _array.GetEnumerator();
|
||||
return _enumerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the enumerator.
|
||||
/// </summary>
|
||||
/// <returns>The enumerator.</returns>
|
||||
public IEnumerator GetEnumerator()
|
||||
{
|
||||
_enumerator = _array.GetEnumerator();
|
||||
return _enumerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next item.
|
||||
/// </summary>
|
||||
/// <returns>The next item.</returns>
|
||||
public T GetNext()
|
||||
{
|
||||
if (_enumerator.MoveNext())
|
||||
{
|
||||
return _enumerator.Current;
|
||||
}
|
||||
else
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/AMWD.Common/Cli/Option.cs
Normal file
113
src/AMWD.Common/Cli/Option.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AMWD.Common.Cli
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a named option.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class Option
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="Option"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The primary name of the option.</param>
|
||||
/// <param name="parameterCount">The number of additional parameters for this option.</param>
|
||||
internal Option(string name, int parameterCount)
|
||||
{
|
||||
Names = [name];
|
||||
ParameterCount = parameterCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of this option.
|
||||
/// </summary>
|
||||
public List<string> Names { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of additional parameters for this option.
|
||||
/// </summary>
|
||||
public int ParameterCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this option is required.
|
||||
/// </summary>
|
||||
public bool IsRequired { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this option can only be specified once.
|
||||
/// </summary>
|
||||
public bool IsSingle { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action to invoke when the option is set.
|
||||
/// </summary>
|
||||
public Action<Argument> Action { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this option is set in the command line.
|
||||
/// </summary>
|
||||
public bool IsSet { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of times that this option is set in the command line.
|
||||
/// </summary>
|
||||
public int SetCount { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Argument"/> instance that contains additional parameters set
|
||||
/// for this option.
|
||||
/// </summary>
|
||||
public Argument Argument { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the <see cref="Argument"/> instance for this option.
|
||||
/// </summary>
|
||||
public string Value => Argument?.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Sets alias names for this option.
|
||||
/// </summary>
|
||||
/// <param name="names">The alias names for this option.</param>
|
||||
/// <returns>The current <see cref="Option"/> instance.</returns>
|
||||
public Option Alias(params string[] names)
|
||||
{
|
||||
Names.AddRange(names);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this option as required. If a required option is not set in the command line,
|
||||
/// an exception is thrown on parsing.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="Option"/> instance.</returns>
|
||||
public Option Required()
|
||||
{
|
||||
IsRequired = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this option as single. If a single option is set multiple times in the
|
||||
/// command line, an exception is thrown on parsing.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="Option"/> instance.</returns>
|
||||
public Option Single()
|
||||
{
|
||||
IsSingle = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the action to invoke when the option is set.
|
||||
/// </summary>
|
||||
/// <param name="action">The action to invoke when the option is set.</param>
|
||||
/// <returns>The current <see cref="Option"/> instance.</returns>
|
||||
public Option Do(Action<Argument> action)
|
||||
{
|
||||
Action = action;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/AMWD.Common/Comparer/DomainComparer.cs
Normal file
57
src/AMWD.Common/Comparer/DomainComparer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AMWD.Common.Comparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a method to compare two domain strings.
|
||||
/// </summary>
|
||||
public class DomainComparer : IComparer<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares two domain strings and returns a value indicating
|
||||
/// whether one is less than, equal to, or greater than the other.
|
||||
/// </summary>
|
||||
/// <param name="x">The first domain string.</param>
|
||||
/// <param name="y">The second domain string.</param>
|
||||
/// <returns>
|
||||
/// A signed integer that indicates the relative values of x and y, as shown in the following table:
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Value</term>
|
||||
/// <description>Meaning</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term>Less than zero</term>
|
||||
/// <description>x is less than y</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Zero</term>
|
||||
/// <description>x equals y</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Greater than zero</term>
|
||||
/// <description>x is greater than y</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </returns>
|
||||
public int Compare(string x, string y)
|
||||
{
|
||||
string[] left = x.Split('.');
|
||||
string[] right = y.Split('.');
|
||||
|
||||
int result = left.Length.CompareTo(right.Length);
|
||||
if (result != 0)
|
||||
return result;
|
||||
|
||||
// Compare from TLD to subdomain with string comparison
|
||||
for (int i = left.Length - 1; i >= 0; i--)
|
||||
{
|
||||
result = left[i].CompareTo(right[i]);
|
||||
if (result != 0)
|
||||
return result;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/AMWD.Common/Comparer/IPAddressComparer.cs
Normal file
71
src/AMWD.Common/Comparer/IPAddressComparer.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
namespace AMWD.Common.Comparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a method to compare two IP addresses.
|
||||
/// </summary>
|
||||
public class IPAddressComparer : IComparer<IPAddress>
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares two IP addresses and returns a value indicating whether one is less than,
|
||||
/// equal to, or greater than the other.
|
||||
/// </summary>
|
||||
/// <param name="x">The first IP address.</param>
|
||||
/// <param name="y">The second IP address.</param>
|
||||
/// <returns>
|
||||
/// A signed integer that indicates the relative values of x and y, as shown in the following table.
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Value</term>
|
||||
/// <description>Meaning</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term>Less than zero</term>
|
||||
/// <description>x is less than y.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Zero</term>
|
||||
/// <description>x equals y.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Greater than zero</term>
|
||||
/// <description>x is greater than y.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </returns>
|
||||
public int Compare(IPAddress x, IPAddress y)
|
||||
{
|
||||
if (x == null && y == null)
|
||||
return 0;
|
||||
|
||||
if (x == null)
|
||||
return -1;
|
||||
|
||||
if (y == null)
|
||||
return 1;
|
||||
|
||||
byte[] xBytes = x.GetAddressBytes();
|
||||
byte[] yBytes = y.GetAddressBytes();
|
||||
|
||||
// Make IPv4 and IPv6 comparable
|
||||
byte[] left = xBytes.Length == 16
|
||||
? xBytes
|
||||
: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, xBytes[0], xBytes[1], xBytes[2], xBytes[3]];
|
||||
byte[] right = yBytes.Length == 16
|
||||
? yBytes
|
||||
: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, yBytes[0], yBytes[1], yBytes[2], yBytes[3]];
|
||||
|
||||
// Compare byte for byte
|
||||
for (int i = 0; i < left.Length; i++)
|
||||
{
|
||||
int result = left[i].CompareTo(right[i]);
|
||||
if (result != 0)
|
||||
return result;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/AMWD.Common/Comparer/VersionStringComparer.cs
Normal file
86
src/AMWD.Common/Comparer/VersionStringComparer.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AMWD.Common.Comparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a method to compare two <see cref="Version"/> strings.
|
||||
/// </summary>
|
||||
#if NET8_0_OR_GREATER
|
||||
public partial class VersionStringComparer : IComparer<string>
|
||||
{
|
||||
private readonly Regex _versionRegex = VersionRegex();
|
||||
#else
|
||||
|
||||
public class VersionStringComparer : IComparer<string>
|
||||
{
|
||||
private readonly Regex _versionRegex = new("([0-9.]+)", RegexOptions.Compiled);
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="Version"/> strings and returns a value indicating
|
||||
/// whether one is less than, equal to, or greater than the other.
|
||||
/// </summary>
|
||||
/// <param name="x">The first version string.</param>
|
||||
/// <param name="y">The second version string.</param>
|
||||
/// <returns>
|
||||
/// A signed integer that indicates the relative values of x and y, as shown in the following table:
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Value</term>
|
||||
/// <description>Meaning</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term>Less than zero</term>
|
||||
/// <description>x is less than y.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Zero</term>
|
||||
/// <description>x equals y.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Greater than zero</term>
|
||||
/// <description>x is greater than y.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </returns>
|
||||
public int Compare(string x, string y)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(x) && string.IsNullOrWhiteSpace(y))
|
||||
return 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(x))
|
||||
return -1;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(y))
|
||||
return 1;
|
||||
|
||||
string xMainVersion = _versionRegex.Match(x).Groups[1].Value;
|
||||
string yMainVersion = _versionRegex.Match(y).Groups[1].Value;
|
||||
|
||||
string xAppendix = x.ReplaceStart(xMainVersion, "");
|
||||
string yAppendix = y.ReplaceStart(yMainVersion, "");
|
||||
|
||||
if (xMainVersion.IndexOf('.') < 0)
|
||||
xMainVersion += ".0";
|
||||
|
||||
if (yMainVersion.IndexOf('.') < 0)
|
||||
yMainVersion += ".0";
|
||||
|
||||
var xVersion = Version.Parse(xMainVersion);
|
||||
var yVersion = Version.Parse(yMainVersion);
|
||||
|
||||
int versionResult = xVersion.CompareTo(yVersion);
|
||||
if (versionResult != 0)
|
||||
return versionResult;
|
||||
|
||||
return xAppendix.CompareTo(yAppendix);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
[GeneratedRegex("([0-9.]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex VersionRegex();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
68
src/AMWD.Common/Converters/ByteArrayHexConverter.cs
Normal file
68
src/AMWD.Common/Converters/ByteArrayHexConverter.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Newtonsoft.Json
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a byte array from and to a hex string.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class ByteArrayHexConverter : JsonConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// List of known types to use this converver.
|
||||
/// </summary>
|
||||
public static readonly Type[] KnownTypes =
|
||||
[
|
||||
typeof(byte[]),
|
||||
typeof(List<byte>),
|
||||
typeof(IEnumerable<byte>)
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool CanConvert(Type objectType)
|
||||
=> KnownTypes.Contains(objectType);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
|
||||
string hex = (string)reader.Value;
|
||||
var byteEnum = hex.HexToBytes();
|
||||
|
||||
if (typeof(byte[]) == objectType)
|
||||
return byteEnum.ToArray();
|
||||
|
||||
if (typeof(List<byte>) == objectType)
|
||||
return byteEnum.ToList();
|
||||
|
||||
return byteEnum;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
string hex = null;
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteValue(hex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value is byte[] byteArray)
|
||||
hex = byteArray.BytesToHex();
|
||||
|
||||
if (value is List<byte> byteList)
|
||||
hex = byteList.BytesToHex();
|
||||
|
||||
if (value is IEnumerable<byte> byteEnum)
|
||||
hex = byteEnum.BytesToHex();
|
||||
|
||||
writer.WriteValue(hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/AMWD.Common/Converters/IPAddressConverter.cs
Normal file
76
src/AMWD.Common/Converters/IPAddressConverter.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
|
||||
namespace Newtonsoft.Json
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an <see cref="IPAddress"/> from and to JSON.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IPAddressConverter : JsonConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// List of known types to use this converver.
|
||||
/// </summary>
|
||||
public static readonly Type[] KnownTypes =
|
||||
[
|
||||
typeof(IPAddress),
|
||||
typeof(IPAddress[]),
|
||||
typeof(List<IPAddress>),
|
||||
typeof(IEnumerable<IPAddress>)
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool CanConvert(Type objectType)
|
||||
=> KnownTypes.Contains(objectType);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
|
||||
string str = (string)reader.Value;
|
||||
|
||||
if (typeof(IPAddress) == objectType)
|
||||
return IPAddress.Parse(str);
|
||||
|
||||
var ips = str.Split(';').Select(s => IPAddress.Parse(s));
|
||||
|
||||
if (typeof(IPAddress[]) == objectType)
|
||||
return ips.ToArray();
|
||||
|
||||
if (typeof(List<IPAddress>) == objectType)
|
||||
return ips.ToList();
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
string str = null;
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteValue(str);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value is IPAddress addr)
|
||||
str = addr.ToString();
|
||||
|
||||
if (value is IPAddress[] addrArray)
|
||||
str = string.Join(";", addrArray.Select(ip => ip.ToString()));
|
||||
|
||||
if (value is List<IPAddress> addrList)
|
||||
str = string.Join(";", addrList.Select(ip => ip.ToString()));
|
||||
|
||||
if (value is IEnumerable<IPAddress> addrEnum)
|
||||
str = string.Join(";", addrEnum.Select(ip => ip.ToString()));
|
||||
|
||||
writer.WriteValue(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/AMWD.Common/Converters/IPNetworkConverter.cs
Normal file
100
src/AMWD.Common/Converters/IPNetworkConverter.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
#if NET8_0_OR_GREATER
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
#else
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
#endif
|
||||
|
||||
namespace Newtonsoft.Json
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an <see cref="IPNetwork"/> from and to JSON.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class IpNetworkConverter : JsonConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// List of known types to use this converver.
|
||||
/// </summary>
|
||||
public static readonly Type[] KnownTypes =
|
||||
[
|
||||
typeof(IPNetwork),
|
||||
typeof(IPNetwork[]),
|
||||
typeof(List<IPNetwork>),
|
||||
typeof(IEnumerable<IPNetwork>)
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool CanConvert(Type objectType)
|
||||
=> KnownTypes.Contains(objectType);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
|
||||
string str = (string)reader.Value;
|
||||
|
||||
if (typeof(IPNetwork) == objectType)
|
||||
return Parse(str);
|
||||
|
||||
var networks = str.Split(';').Select(Parse);
|
||||
|
||||
if (typeof(IPNetwork[]) == objectType)
|
||||
return networks.ToArray();
|
||||
|
||||
if (typeof(List<IPNetwork>) == objectType)
|
||||
return networks.ToList();
|
||||
|
||||
return networks;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
string str = null;
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteValue(str);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value is IPNetwork net)
|
||||
str = ToString(net);
|
||||
|
||||
if (value is IPNetwork[] netArray)
|
||||
str = string.Join(";", netArray.Select(ToString));
|
||||
|
||||
if (value is List<IPNetwork> netList)
|
||||
str = string.Join(";", netList.Select(ToString));
|
||||
|
||||
if (value is IEnumerable<IPNetwork> netEnum)
|
||||
str = string.Join(";", netEnum.Select(ToString));
|
||||
|
||||
writer.WriteValue(str);
|
||||
}
|
||||
|
||||
private static string ToString(IPNetwork net)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return $"{net.BaseAddress}/{net.PrefixLength}";
|
||||
#else
|
||||
return $"{net.Prefix}/{net.PrefixLength}";
|
||||
#endif
|
||||
}
|
||||
|
||||
private static IPNetwork Parse(string str)
|
||||
{
|
||||
string[] parts = str.Split('/');
|
||||
var prefix = IPAddress.Parse(parts.First());
|
||||
int prefixLength = int.Parse(parts.Last());
|
||||
|
||||
return new IPNetwork(prefix, prefixLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/AMWD.Common/Extensions/CollectionExtensions.cs
Normal file
55
src/AMWD.Common/Extensions/CollectionExtensions.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace System.Collections.Generic
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for generic <see cref="ICollection{T}"/> implementations.
|
||||
/// </summary>
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add the <paramref name="item"/> to the <paramref name="collection"/> if it is not <see langword="null"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the collection</typeparam>
|
||||
/// <param name="collection">The collection.</param>
|
||||
/// <param name="item">The item to add if not <see langword="null"/>.</param>
|
||||
public static void AddIfNotNull<T>(this ICollection<T> collection, T item)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(collection);
|
||||
#else
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
#endif
|
||||
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
collection.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a <see cref="AddRange{T}(ICollection{T}, IEnumerable{T})"/> functionallity to <see cref="ICollection{T}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the collection.</typeparam>
|
||||
/// <param name="collection">The collection.</param>
|
||||
/// <param name="items">The items to add.</param>
|
||||
public static void AddRange<T>(this ICollection<T> collection, IEnumerable<T> items)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(collection);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
#else
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
if (items == null)
|
||||
throw new ArgumentNullException(nameof(items));
|
||||
#endif
|
||||
|
||||
if (collection == items)
|
||||
return;
|
||||
|
||||
foreach (var item in items)
|
||||
collection.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/AMWD.Common/Extensions/CryptographyHelperExtensions.cs
Normal file
84
src/AMWD.Common/Extensions/CryptographyHelperExtensions.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace System.Security.Cryptography
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="CryptographyHelper"/> class.
|
||||
/// </summary>
|
||||
public static class CryptographyHelperExtensions
|
||||
{
|
||||
#region Hashing
|
||||
|
||||
#region MD5
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the MD5 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The MD5 hash value, in hexadecimal notation.</returns>
|
||||
public static string Md5(this string str) => CryptographyHelper.Md5(str);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the MD5 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The MD5 hash value, in hexadecimal notation.</returns>
|
||||
public static string Md5(this byte[] bytes) => CryptographyHelper.Md5(bytes);
|
||||
|
||||
#endregion MD5
|
||||
|
||||
#region SHA-1
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-1 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-1 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha1(this string str) => CryptographyHelper.Sha1(str);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-1 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-1 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha1(this byte[] bytes) => CryptographyHelper.Sha1(bytes);
|
||||
|
||||
#endregion SHA-1
|
||||
|
||||
#region SHA-256
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-256 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-256 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha256(this string str) => CryptographyHelper.Sha256(str);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-256 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-256 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha256(this byte[] bytes) => CryptographyHelper.Sha256(bytes);
|
||||
|
||||
#endregion SHA-256
|
||||
|
||||
#region SHA-512
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-512 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-512 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha512(this string str) => CryptographyHelper.Sha512(str);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-512 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-512 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha512(this byte[] bytes) => CryptographyHelper.Sha512(bytes);
|
||||
|
||||
#endregion SHA-512
|
||||
|
||||
#endregion Hashing
|
||||
}
|
||||
}
|
||||
195
src/AMWD.Common/Extensions/DateTimeExtensions.cs
Normal file
195
src/AMWD.Common/Extensions/DateTimeExtensions.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Text;
|
||||
|
||||
namespace System
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for date and time manipulation.
|
||||
/// </summary>
|
||||
public static class DateTimeExtensions
|
||||
{
|
||||
#region Kind
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the <see cref="DateTime.Kind"/> as UTC.
|
||||
/// </summary>
|
||||
/// <param name="dt">The <see cref="DateTime"/> instance.</param>
|
||||
/// <returns>A <see cref="DateTime"/> with correct <see cref="DateTime.Kind"/>.</returns>
|
||||
public static DateTime AsUtc(this DateTime dt)
|
||||
{
|
||||
return dt.Kind switch
|
||||
{
|
||||
DateTimeKind.Local => dt.ToUniversalTime(),
|
||||
DateTimeKind.Utc => dt,
|
||||
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the <see cref="DateTime.Kind"/> as local time.
|
||||
/// </summary>
|
||||
/// <param name="dt">The <see cref="DateTime"/> instance.</param>
|
||||
/// <returns>A <see cref="DateTime"/> with correct <see cref="DateTime.Kind"/>.</returns>
|
||||
public static DateTime AsLocal(this DateTime dt)
|
||||
{
|
||||
return dt.Kind switch
|
||||
{
|
||||
DateTimeKind.Local => dt,
|
||||
DateTimeKind.Utc => dt.ToLocalTime(),
|
||||
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local),
|
||||
};
|
||||
}
|
||||
|
||||
#endregion Kind
|
||||
|
||||
#region Aligned Interval
|
||||
|
||||
/// <summary>
|
||||
/// Aligns the <see cref="TimeSpan"/> to the UTC clock.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The timespan to align.</param>
|
||||
/// <param name="offset">A specific offset to the timespan.</param>
|
||||
/// <returns>The timespan until the aligned time.</returns>
|
||||
public static TimeSpan GetAlignedIntervalUtc(this TimeSpan timeSpan, TimeSpan offset = default)
|
||||
=> timeSpan.GetAlignedInterval(DateTime.UtcNow, offset);
|
||||
|
||||
/// <summary>
|
||||
/// Aligns the <see cref="TimeSpan"/> to the local clock and respects daylight saving time.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The timespan to align.</param>
|
||||
/// <param name="offset">A specific offset to the timespan.</param>
|
||||
/// <returns>The timespan until the aligned time.</returns>
|
||||
public static TimeSpan GetAlignedIntervalLocal(this TimeSpan timeSpan, TimeSpan offset = default)
|
||||
=> timeSpan.GetAlignedInterval(DateTime.Now, offset);
|
||||
|
||||
/// <summary>
|
||||
/// Aligns the <see cref="TimeSpan"/> to the specified time.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The timespan to align.</param>
|
||||
/// <param name="now">A timestamp to align with.</param>
|
||||
/// <param name="offset">A specific offset to the timespan.</param>
|
||||
/// <returns>The timespan until the aligned time.</returns>
|
||||
public static TimeSpan GetAlignedInterval(this TimeSpan timeSpan, DateTime now, TimeSpan offset = default)
|
||||
{
|
||||
var dtOffsetNow = new DateTimeOffset(now);
|
||||
|
||||
var nextTime = new DateTime(dtOffsetNow.Ticks / timeSpan.Ticks * timeSpan.Ticks, now.Kind).Add(offset);
|
||||
var dtOffsetNext = new DateTimeOffset(nextTime);
|
||||
|
||||
if (dtOffsetNext <= dtOffsetNow)
|
||||
dtOffsetNext = dtOffsetNext.Add(timeSpan);
|
||||
|
||||
if (now.Kind == DateTimeKind.Local)
|
||||
return dtOffsetNext.LocalDateTime - dtOffsetNow.LocalDateTime;
|
||||
|
||||
return dtOffsetNext - dtOffsetNow;
|
||||
}
|
||||
|
||||
#endregion Aligned Interval
|
||||
|
||||
/// <summary>
|
||||
/// Prints the timespan as shortended string.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The timespan</param>
|
||||
/// <param name="withMilliseconds">A value indicating whether to show milliseconds.</param>
|
||||
/// <returns>The timespan as string.</returns>
|
||||
public static string ToShortString(this TimeSpan timeSpan, bool withMilliseconds = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (timeSpan < TimeSpan.Zero)
|
||||
sb.Append("-");
|
||||
|
||||
if (timeSpan.TotalDays != 0)
|
||||
sb.Append(Math.Abs(timeSpan.Days)).Append("d ");
|
||||
|
||||
if (timeSpan.TotalHours != 0)
|
||||
sb.Append(Math.Abs(timeSpan.Hours)).Append("h ");
|
||||
|
||||
if (timeSpan.TotalMinutes != 0)
|
||||
sb.Append(Math.Abs(timeSpan.Minutes)).Append("m ");
|
||||
|
||||
sb.Append(Math.Abs(timeSpan.Seconds)).Append("s ");
|
||||
|
||||
if (withMilliseconds)
|
||||
sb.Append(Math.Abs(timeSpan.Milliseconds)).Append("ms");
|
||||
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
#region Round DateTime
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="DateTime"/> to full seconds.
|
||||
/// </summary>
|
||||
/// <param name="dt">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
|
||||
public static DateTime RoundToSecond(this DateTime dt)
|
||||
=> new(RoundTicks(dt.Ticks, TimeSpan.TicksPerSecond), dt.Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="DateTime"/> to full minutes.
|
||||
/// </summary>
|
||||
/// <param name="dt">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static DateTime RoundToMinute(this DateTime dt)
|
||||
=> new(RoundTicks(dt.Ticks, TimeSpan.TicksPerMinute), dt.Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="DateTime"/> to full hours.
|
||||
/// </summary>
|
||||
/// <param name="dt">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static DateTime RoundToHour(this DateTime dt)
|
||||
=> new(RoundTicks(dt.Ticks, TimeSpan.TicksPerHour), dt.Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="DateTime"/> to full days.
|
||||
/// </summary>
|
||||
/// <param name="dt">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static DateTime RoundToDay(this DateTime dt)
|
||||
=> new(RoundTicks(dt.Ticks, TimeSpan.TicksPerDay), dt.Kind);
|
||||
|
||||
#endregion Round DateTime
|
||||
|
||||
#region Round TimeSpan
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="TimeSpan"/> to full seconds.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static TimeSpan RoundToSecond(this TimeSpan timeSpan)
|
||||
=> new(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerSecond));
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="TimeSpan"/> to full minutes.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static TimeSpan RoundToMinute(this TimeSpan timeSpan)
|
||||
=> new(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerMinute));
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="TimeSpan"/> to full hours.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static TimeSpan RoundToHour(this TimeSpan timeSpan)
|
||||
=> new(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerHour));
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the <see cref="TimeSpan"/> to full days.
|
||||
/// </summary>
|
||||
/// <param name="timeSpan">The time value to round.</param>
|
||||
/// <returns></returns>
|
||||
public static TimeSpan RoundToDay(this TimeSpan timeSpan)
|
||||
=> new(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerDay));
|
||||
|
||||
#endregion Round TimeSpan
|
||||
|
||||
private static long RoundTicks(long ticks, long value)
|
||||
=> (ticks + value / 2) / value * value;
|
||||
}
|
||||
}
|
||||
44
src/AMWD.Common/Extensions/EnumExtensions.cs
Normal file
44
src/AMWD.Common/Extensions/EnumExtensions.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace System
|
||||
{
|
||||
/// <summary>
|
||||
/// Extend the enum values by attribute driven methods.
|
||||
/// </summary>
|
||||
public static class EnumExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a list of specific attribute type from a enum-value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAttribute">The attribute type.</typeparam>
|
||||
/// <param name="value">The enum value.</param>
|
||||
/// <returns>The attributes or null.</returns>
|
||||
public static IEnumerable<TAttribute> GetAttributes<TAttribute>(this Enum value)
|
||||
{
|
||||
var fieldInfo = value.GetType().GetField(value.ToString());
|
||||
if (fieldInfo == null)
|
||||
return Array.Empty<TAttribute>();
|
||||
|
||||
return fieldInfo.GetCustomAttributes(typeof(TAttribute), inherit: false).Cast<TAttribute>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a specific attribute from a enum-value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAttribute">The attribute type.</typeparam>
|
||||
/// <param name="value">The enum value.</param>
|
||||
/// <returns>The attribute or null.</returns>
|
||||
public static TAttribute GetAttribute<TAttribute>(this Enum value)
|
||||
=> value.GetAttributes<TAttribute>().FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the description from <see cref="DescriptionAttribute"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The enum value.</param>
|
||||
/// <returns>The description or the string representation of the value.</returns>
|
||||
public static string GetDescription(this Enum value)
|
||||
=> value.GetAttribute<DescriptionAttribute>()?.Description ?? value.ToString();
|
||||
}
|
||||
}
|
||||
41
src/AMWD.Common/Extensions/ExceptionExtensions.cs
Normal file
41
src/AMWD.Common/Extensions/ExceptionExtensions.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace System
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for exceptions.
|
||||
/// </summary>
|
||||
public static class ExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the message of the inner exception if exists otherwise the message of the exception itself.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception.</param>
|
||||
/// <returns>The message of the inner exception or the exception itself.</returns>
|
||||
public static string GetMessage(this Exception exception)
|
||||
=> exception.InnerException?.Message ?? exception.Message;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the message of the exception and its inner exceptions.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception.</param>
|
||||
/// <returns>The message of the <paramref name="exception"/> and all its inner exceptions.</returns>
|
||||
public static string GetRecursiveMessage(this Exception exception)
|
||||
{
|
||||
if (exception is AggregateException aggregateEx)
|
||||
{
|
||||
return aggregateEx.InnerExceptions
|
||||
.Take(3)
|
||||
.Select(ex => ex.GetRecursiveMessage())
|
||||
.Aggregate((a, b) => a + " " + b);
|
||||
}
|
||||
if (exception.InnerException != null)
|
||||
{
|
||||
string message = exception.Message;
|
||||
message = message.ReplaceEnd(" See the inner exception for details.", "");
|
||||
return message + " " + exception.InnerException.GetRecursiveMessage();
|
||||
}
|
||||
return exception.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/AMWD.Common/Extensions/IPAddressExtensions.cs
Normal file
52
src/AMWD.Common/Extensions/IPAddressExtensions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace System.Net
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="IPAddress"/>es.
|
||||
/// </summary>
|
||||
public static class IPAddressExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Increments the <see cref="IPAddress"/> by one and returns a new instance.
|
||||
/// </summary>
|
||||
/// <param name="address">The <see cref="IPAddress"/> to increment.</param>
|
||||
public static IPAddress Increment(this IPAddress address)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
int bytePos = bytes.Length - 1;
|
||||
|
||||
while (bytes[bytePos] == byte.MaxValue)
|
||||
{
|
||||
bytes[bytePos] = 0;
|
||||
bytePos--;
|
||||
|
||||
if (bytePos < 0)
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
bytes[bytePos]++;
|
||||
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the <see cref="IPAddress"/> by one and returns a new instance.
|
||||
/// </summary>
|
||||
/// <param name="address">The <see cref="IPAddress"/> to decrement.</param>
|
||||
public static IPAddress Decrement(this IPAddress address)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
int bytePos = bytes.Length - 1;
|
||||
|
||||
while (bytes[bytePos] == 0)
|
||||
{
|
||||
bytes[bytePos] = byte.MaxValue;
|
||||
bytePos--;
|
||||
|
||||
if (bytePos < 0)
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
bytes[bytePos]--;
|
||||
|
||||
return new IPAddress(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
182
src/AMWD.Common/Extensions/JsonExtensions.cs
Normal file
182
src/AMWD.Common/Extensions/JsonExtensions.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Unclassified.Util;
|
||||
|
||||
namespace Newtonsoft.Json
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods to serialize and deserialize JSON values to/from objects using
|
||||
/// common naming conventions.
|
||||
/// </summary>
|
||||
public static class JsonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Common JSON serializer settings.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerSettings _jsonSerializerSettings = new()
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
Culture = CultureInfo.InvariantCulture
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Populates an instance with values deserialized from a JSON string.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to populate.</typeparam>
|
||||
/// <param name="target">The instance to populate.</param>
|
||||
/// <param name="json">The JSON string to read the values from.</param>
|
||||
public static void DeserializeJson<T>(this T target, string json)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(json))
|
||||
JsonConvert.PopulateObject(json, target, _jsonSerializerSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON string into a new instance.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to deserialize.</typeparam>
|
||||
/// <param name="json">The JSON string to read the values from.</param>
|
||||
/// <returns>A new instance of <typeparamref name="T"/> with the deserialized values.</returns>
|
||||
public static T DeserializeJson<T>(this string json)
|
||||
=> JsonConvert.DeserializeObject<T>(json, _jsonSerializerSettings);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON string into a new instance or using the fallback value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to deserialize.</typeparam>
|
||||
/// <param name="json">The JSON string to read the values from.</param>
|
||||
/// <param name="fallbackValue">A fallback value when deserialization fails.</param>
|
||||
/// <returns>A new instance of <typeparamref name="T"/> with the deserialized values or the fallback value.</returns>
|
||||
public static T DeserializeJson<T>(this string json, T fallbackValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<T>(json, _jsonSerializerSettings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an instance to a JSON string.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to serialize.</typeparam>
|
||||
/// <param name="source">The instance to serialize.</param>
|
||||
/// <param name="indented">Indicates whether the JSON string is indented to make it better readable.</param>
|
||||
/// <param name="useSingleQuotes">Indicates whether the JSON string uses single quotes instead of double quotes.</param>
|
||||
/// <param name="useCamelCase">Indicates whether the camelCase conversion should be used.</param>
|
||||
/// <param name="includeType">Indicates whether to include the instance type of <paramref name="source"/> if it is not <typeparamref name="T"/>.</param>
|
||||
/// <returns>The JSON-serialized string.</returns>
|
||||
public static string SerializeJson<T>(this T source, bool indented = false, bool useSingleQuotes = false, bool useCamelCase = true, bool includeType = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using (var sw = new StringWriter(sb))
|
||||
using (var jw = new JsonTextWriter(sw))
|
||||
{
|
||||
if (useSingleQuotes)
|
||||
jw.QuoteChar = '\'';
|
||||
|
||||
jw.Formatting = indented ? Formatting.Indented : Formatting.None;
|
||||
var serializer = useCamelCase ? JsonSerializer.Create(_jsonSerializerSettings) : JsonSerializer.CreateDefault();
|
||||
|
||||
serializer.Error += (s, a) =>
|
||||
{
|
||||
a.ErrorContext.Handled = true;
|
||||
};
|
||||
|
||||
serializer.TypeNameHandling = includeType ? TypeNameHandling.All : TypeNameHandling.None;
|
||||
|
||||
serializer.Serialize(jw, source, typeof(T));
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an object into a JObject using the custom serializer settings.
|
||||
/// </summary>
|
||||
/// <param name="obj">The object to convert.</param>
|
||||
/// <returns>A JObject representing the <paramref name="obj"/>.</returns>
|
||||
public static JObject ConvertToJObject(this object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
return null;
|
||||
|
||||
var serializer = JsonSerializer.Create(_jsonSerializerSettings);
|
||||
return JObject.FromObject(obj, serializer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an enumerable into a JArray using the custom serializer settings.
|
||||
/// </summary>
|
||||
/// <param name="array">The enumerable to convert.</param>
|
||||
/// <returns>A JArray representing the <paramref name="array"/>.</returns>
|
||||
public static JArray ConvertToJArray(this IEnumerable array)
|
||||
{
|
||||
if (array == null)
|
||||
return null;
|
||||
|
||||
var serializer = JsonSerializer.Create(_jsonSerializerSettings);
|
||||
return JArray.FromObject(array, serializer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the object using multiple levels.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to convert the data to.</typeparam>
|
||||
/// <param name="jObj">The object.</param>
|
||||
/// <param name="key">The key to the value.</param>
|
||||
/// <param name="defaultValue">The default value when the key was not found.</param>
|
||||
/// <param name="keySplit">The character to split the key in levels (default: colon).</param>
|
||||
/// <returns>The converted value.</returns>
|
||||
public static T GetValue<T>(this JObject jObj, string key, T defaultValue, char keySplit = ':')
|
||||
{
|
||||
if (jObj == null)
|
||||
return defaultValue;
|
||||
|
||||
string[] levels = key.Split(keySplit);
|
||||
JToken lvlObj = jObj;
|
||||
foreach (string level in levels)
|
||||
{
|
||||
if (lvlObj == null)
|
||||
return defaultValue;
|
||||
|
||||
string lvl = level;
|
||||
if (lvlObj.Type == JTokenType.Object)
|
||||
{
|
||||
foreach (var prop in ((JObject)lvlObj).Properties())
|
||||
{
|
||||
if (prop.Name.Equals(lvl, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lvl = prop.Name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lvlObj = lvlObj[lvl];
|
||||
}
|
||||
|
||||
if (lvlObj == null)
|
||||
return defaultValue;
|
||||
|
||||
return DeepConvert.ChangeType<T>(lvlObj is JValue lvlValue ? lvlValue.Value : lvlObj.Value<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the object using multiple levels.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to convert the data to.</typeparam>
|
||||
/// <param name="jObj">The object.</param>
|
||||
/// <param name="key">The key to the value.</param>
|
||||
/// <param name="keySplit">The character to split the key in levels (default: colon).</param>
|
||||
/// <returns>The converted value.</returns>
|
||||
public static T GetValue<T>(this JObject jObj, string key, char keySplit = ':')
|
||||
=> jObj.GetValue(key, default(T), keySplit);
|
||||
}
|
||||
}
|
||||
91
src/AMWD.Common/Extensions/ReaderWriterLockSlimExtensions.cs
Normal file
91
src/AMWD.Common/Extensions/ReaderWriterLockSlimExtensions.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
namespace System.Threading
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="ReaderWriterLockSlim"/>.
|
||||
/// </summary>
|
||||
public static class ReaderWriterLockSlimExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Acquires a read lock on a lock object that can be released with an
|
||||
/// <see cref="IDisposable"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="rwLock">The lock object.</param>
|
||||
/// <param name="timeoutMilliseconds">The number of milliseconds to wait, or -1
|
||||
/// (<see cref="Timeout.Infinite"/>) to wait indefinitely.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> instance to release the lock.</returns>
|
||||
public static IDisposable GetReadLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1)
|
||||
{
|
||||
if (!rwLock.TryEnterReadLock(timeoutMilliseconds))
|
||||
throw new TimeoutException("The read lock could not be acquired.");
|
||||
|
||||
return new DisposableReadWriteLock(rwLock, LockMode.Read);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a upgradeable read lock on a lock object that can be released with an
|
||||
/// <see cref="IDisposable"/> instance. The lock can be upgraded to a write lock temporarily
|
||||
/// with <see cref="GetWriteLock"/> or until the lock is released with
|
||||
/// <see cref="ReaderWriterLockSlim.EnterWriteLock"/> alone.
|
||||
/// </summary>
|
||||
/// <param name="rwLock">The lock object.</param>
|
||||
/// <param name="timeoutMilliseconds">The number of milliseconds to wait, or -1
|
||||
/// (<see cref="Timeout.Infinite"/>) to wait indefinitely.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> instance to release the lock. If the lock was
|
||||
/// upgraded to a write lock, that will be released as well.</returns>
|
||||
public static IDisposable GetUpgradeableReadLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1)
|
||||
{
|
||||
if (!rwLock.TryEnterUpgradeableReadLock(timeoutMilliseconds))
|
||||
throw new TimeoutException("The upgradeable read lock could not be acquired.");
|
||||
|
||||
return new DisposableReadWriteLock(rwLock, LockMode.Upgradable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a write lock on a lock object that can be released with an
|
||||
/// <see cref="IDisposable"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="rwLock">The lock object.</param>
|
||||
/// <param name="timeoutMilliseconds">The number of milliseconds to wait, or -1
|
||||
/// (<see cref="Timeout.Infinite"/>) to wait indefinitely.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> instance to release the lock.</returns>
|
||||
public static IDisposable GetWriteLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1)
|
||||
{
|
||||
if (!rwLock.TryEnterWriteLock(timeoutMilliseconds))
|
||||
throw new TimeoutException("The write lock could not be acquired.");
|
||||
|
||||
return new DisposableReadWriteLock(rwLock, LockMode.Write);
|
||||
}
|
||||
|
||||
private struct DisposableReadWriteLock(ReaderWriterLockSlim rwLock, LockMode lockMode)
|
||||
: IDisposable
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _rwLock = rwLock;
|
||||
private LockMode _lockMode = lockMode;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_lockMode == LockMode.Read)
|
||||
_rwLock.ExitReadLock();
|
||||
|
||||
if (_lockMode == LockMode.Upgradable && _rwLock.IsWriteLockHeld) // Upgraded with EnterWriteLock alone
|
||||
_rwLock.ExitWriteLock();
|
||||
|
||||
if (_lockMode == LockMode.Upgradable)
|
||||
_rwLock.ExitUpgradeableReadLock();
|
||||
|
||||
if (_lockMode == LockMode.Write)
|
||||
_rwLock.ExitWriteLock();
|
||||
|
||||
_lockMode = LockMode.None;
|
||||
}
|
||||
}
|
||||
|
||||
private enum LockMode
|
||||
{
|
||||
None = 0,
|
||||
Read = 1,
|
||||
Upgradable = 2,
|
||||
Write = 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/AMWD.Common/Extensions/ReflectionExtensions.cs
Normal file
42
src/AMWD.Common/Extensions/ReflectionExtensions.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AMWD.Common.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="System.Reflection"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class ReflectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Calls a method from it's reflection asynchronously without result.
|
||||
/// </summary>
|
||||
/// <param name="methodInfo">The <see cref="MethodInfo"/> to call on an object.</param>
|
||||
/// <param name="obj">The reflected instance to call the method on.</param>
|
||||
/// <param name="parameters">The parameters of the called method.</param>
|
||||
/// <returns>An awaitable <see cref="Task"/>.</returns>
|
||||
public static async Task CallAsync(this MethodInfo methodInfo, object obj, params object[] parameters)
|
||||
{
|
||||
var task = (Task)methodInfo.Invoke(obj, parameters);
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a method from it's reflection asynchronously with a result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The result type, that is expected (and casted to).</typeparam>
|
||||
/// <param name="methodInfo">The <see cref="MethodInfo"/> to invoke on an object.</param>
|
||||
/// <param name="obj">The reflected instance to invoke the method on.</param>
|
||||
/// <param name="parameters">The parameters of the called method.</param>
|
||||
/// <returns>An awaitable <see cref="Task"/> with result.</returns>
|
||||
public static async Task<TResult> InvokeAsync<TResult>(this MethodInfo methodInfo, object obj, params object[] parameters)
|
||||
{
|
||||
var task = (Task)methodInfo.Invoke(obj, parameters);
|
||||
await task.ConfigureAwait(false);
|
||||
|
||||
var resultPropertyInfo = task.GetType().GetProperty("Result");
|
||||
return (TResult)resultPropertyInfo.GetValue(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/AMWD.Common/Extensions/StreamExtensions.cs
Normal file
79
src/AMWD.Common/Extensions/StreamExtensions.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace System.IO
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="Stream"/>.
|
||||
/// </summary>
|
||||
public static class StreamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a line from a <see cref="Stream"/>.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to read from.</param>
|
||||
/// <param name="encoding">The encoding to use.</param>
|
||||
/// <param name="eol">The character determinating a line end.</param>
|
||||
/// <returns></returns>
|
||||
public static string ReadLine(this Stream stream, Encoding encoding = null, char? eol = null)
|
||||
{
|
||||
encoding ??= Encoding.Default;
|
||||
eol ??= Environment.NewLine.Last();
|
||||
|
||||
if (!stream.CanRead)
|
||||
return null;
|
||||
|
||||
var bytes = new List<byte>();
|
||||
char ch;
|
||||
do
|
||||
{
|
||||
int result = stream.ReadByte();
|
||||
if (result == -1)
|
||||
break;
|
||||
|
||||
byte b = (byte)result;
|
||||
bytes.Add(b);
|
||||
ch = (char)result;
|
||||
}
|
||||
while (ch != eol);
|
||||
|
||||
return encoding.GetString([.. bytes]).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line from a <see cref="Stream"/> asynchronous.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to read from.</param>
|
||||
/// <param name="encoding">The encoding to use.</param>
|
||||
/// <param name="eol">The character determinating a line end.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests. The default value is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns></returns>
|
||||
public static async Task<string> ReadLineAsync(this Stream stream, Encoding encoding = null, char? eol = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
encoding ??= Encoding.Default;
|
||||
eol ??= Environment.NewLine.Last();
|
||||
|
||||
if (!stream.CanRead)
|
||||
return null;
|
||||
|
||||
var bytes = new List<byte>();
|
||||
char ch;
|
||||
do
|
||||
{
|
||||
byte[] buffer = new byte[1];
|
||||
int count = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
|
||||
if (count == 0)
|
||||
break;
|
||||
|
||||
bytes.Add(buffer[0]);
|
||||
ch = (char)buffer[0];
|
||||
}
|
||||
while (ch != eol);
|
||||
|
||||
return encoding.GetString([.. bytes]).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/AMWD.Common/Extensions/StringExtensions.cs
Normal file
258
src/AMWD.Common/Extensions/StringExtensions.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AMWD.Common.Extensions;
|
||||
|
||||
namespace System
|
||||
{
|
||||
/// <summary>
|
||||
/// String extensions.
|
||||
/// </summary>
|
||||
#if NET8_0_OR_GREATER
|
||||
public static partial class StringExtensions
|
||||
#else
|
||||
|
||||
public static class StringExtensions
|
||||
#endif
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a hex string into a byte array.
|
||||
/// </summary>
|
||||
/// <param name="hexString">The hex encoded string.</param>
|
||||
/// <param name="delimiter">A delimiter between the bytes (e.g. for MAC address).</param>
|
||||
/// <returns>The bytes.</returns>
|
||||
public static IEnumerable<byte> HexToBytes(this string hexString, string delimiter = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hexString))
|
||||
yield break;
|
||||
|
||||
string str = string.IsNullOrEmpty(delimiter) ? hexString : hexString.Replace(delimiter, "");
|
||||
if (str.Length % 2 == 1)
|
||||
yield break;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
if (InvalidHexCharRegex().IsMatch(str))
|
||||
yield break;
|
||||
#else
|
||||
if (Regex.IsMatch(str, "[^0-9a-fA-F]", RegexOptions.Compiled))
|
||||
yield break;
|
||||
#endif
|
||||
|
||||
for (int i = 0; i < str.Length; i += 2)
|
||||
yield return Convert.ToByte(str.Substring(i, 2), 16);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a byte collection into a hex string.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The bytes.</param>
|
||||
/// <param name="delimiter">A delimiter to set between the bytes (e.g. for MAC address).</param>
|
||||
/// <returns>The hex encoded string.</returns>
|
||||
public static string BytesToHex(this IEnumerable<byte> bytes, string delimiter = "")
|
||||
{
|
||||
if (bytes?.Any() != true)
|
||||
return null;
|
||||
|
||||
return string.Join(delimiter, bytes.Select(b => b.ToString("x2")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a string to the hexadecimal system (base 16).
|
||||
/// </summary>
|
||||
/// <param name="str">The string to encode hexadecimal.</param>
|
||||
/// <param name="encoding">The text encoding to use (default: <see cref="Encoding.Default"/>)</param>
|
||||
/// <returns></returns>
|
||||
public static string HexEncode(this string str, Encoding encoding = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str))
|
||||
return str;
|
||||
|
||||
return (encoding ?? Encoding.Default).GetBytes(str).BytesToHex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a string from the hexadecimal system (base 16).
|
||||
/// </summary>
|
||||
/// <param name="str">The hexadecimal encoded string to decode.</param>
|
||||
/// <param name="encoding">The text encoding to use (default: <see cref="Encoding.Default"/>)</param>
|
||||
/// <returns></returns>
|
||||
public static string HexDecode(this string str, Encoding encoding = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str))
|
||||
return str;
|
||||
|
||||
return (encoding ?? Encoding.Default).GetString(str.HexToBytes().ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a string to base64.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to encode with base64.</param>
|
||||
/// <param name="encoding">The text encoding to use (default: <see cref="Encoding.Default"/>)</param>
|
||||
/// <returns></returns>
|
||||
public static string Base64Encode(this string str, Encoding encoding = null)
|
||||
=> Convert.ToBase64String((encoding ?? Encoding.Default).GetBytes(str));
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a string from base64.
|
||||
/// </summary>
|
||||
/// <param name="str">The base64 encoded string to decode.</param>
|
||||
/// <param name="encoding">The text encoding to use (default: <see cref="Encoding.Default"/>)</param>
|
||||
/// <returns></returns>
|
||||
public static string Base64Decode(this string str, Encoding encoding = null)
|
||||
=> (encoding ?? Encoding.Default).GetString(Convert.FromBase64String(str));
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the search substring with the replacement when it was found at the beginning of the string.
|
||||
/// </summary>
|
||||
/// <param name="str">The string.</param>
|
||||
/// <param name="search">The searched substring.</param>
|
||||
/// <param name="replacement">The replacement.</param>
|
||||
/// <returns></returns>
|
||||
public static string ReplaceStart(this string str, string search, string replacement)
|
||||
{
|
||||
if (str.StartsWith(search))
|
||||
return replacement + str.Substring(search.Length);
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the search substring with the replacement when it was found at the end of the string.
|
||||
/// </summary>
|
||||
/// <param name="str">The string.</param>
|
||||
/// <param name="search">The searched substring.</param>
|
||||
/// <param name="replacement">The replacement.</param>
|
||||
/// <returns></returns>
|
||||
public static string ReplaceEnd(this string str, string search, string replacement)
|
||||
{
|
||||
if (str.EndsWith(search))
|
||||
return str.Substring(0, str.Length - search.Length) + replacement;
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a <see cref="string"/> to a <see cref="decimal"/> using culture "de" or invariant.
|
||||
/// </summary>
|
||||
/// <param name="decString">The string to parse.</param>
|
||||
/// <returns></returns>
|
||||
public static decimal ParseDecimal(this string decString)
|
||||
{
|
||||
int dotIndex = decString.LastIndexOf('.');
|
||||
int commaIndex = decString.LastIndexOf(',');
|
||||
|
||||
var culture = dotIndex < commaIndex ? new CultureInfo("de-DE") : CultureInfo.InvariantCulture;
|
||||
|
||||
return decimal.Parse(decString, culture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given <see cref="string"/> is a valid <see cref="MailAddress"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// You can enhance the check by requesting the MX record of the domain.
|
||||
/// </remarks>
|
||||
/// <param name="email">The email address as string to validate.</param>
|
||||
/// <param name="checkForDnsRecord">A value indicating whether to resolve a MX record (Google DNS is used).</param>
|
||||
/// <returns><c>true</c> when the email address is valid, other wise <c>false</c>.</returns>
|
||||
public static bool IsValidEmailAddress(this string email, bool checkForDnsRecord = false)
|
||||
=> email.IsValidEmailAddress(checkForDnsRecord ? new[] { new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53) } : null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given <see cref="string"/> is a valid <see cref="MailAddress"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The check is enhanced by a request for MX records on the defined <paramref name="nameservers"/>.
|
||||
/// <br/>
|
||||
/// The DNS resolution is only used when the DNS NuGet package is available.
|
||||
/// See: https://www.nuget.org/packages/DNS/7.0.0
|
||||
/// </remarks>
|
||||
/// <param name="emailAddress">The email address as string to validate.</param>
|
||||
/// <param name="nameservers">A list of <see cref="IPEndPoint"/>s of nameservers.</param>
|
||||
/// <returns><c>true</c> when the email address is valid, other wise <c>false</c>.</returns>
|
||||
public static bool IsValidEmailAddress(this string emailAddress, IEnumerable<IPEndPoint> nameservers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(emailAddress))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var mailAddress = new MailAddress(emailAddress);
|
||||
if (mailAddress.Address != emailAddress)
|
||||
return false;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
if (!ValidEmailRegex().IsMatch(emailAddress))
|
||||
return false;
|
||||
#else
|
||||
string emailRegex = @"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$";
|
||||
if (!Regex.IsMatch(emailAddress, emailRegex, RegexOptions.Compiled))
|
||||
return false;
|
||||
#endif
|
||||
|
||||
if (nameservers?.Any() == true)
|
||||
{
|
||||
var dnsClientType = Type.GetType("DNS.Client.DnsClient, DNS") ?? throw new DllNotFoundException("The DNS NuGet package is required: https://www.nuget.org/packages/DNS/7.0.0");
|
||||
var recordTypeType = Type.GetType("DNS.Protocol.RecordType, DNS");
|
||||
var resolveMethodInfo = dnsClientType.GetMethod("Resolve", [typeof(string), recordTypeType, typeof(CancellationToken)]);
|
||||
|
||||
foreach (var nameserver in nameservers)
|
||||
{
|
||||
object dnsClient = Activator.CreateInstance(dnsClientType, [nameserver]);
|
||||
|
||||
var waitTask = Task.Run(async () => await resolveMethodInfo.InvokeAsync<object>(dnsClient, [mailAddress.Host, 15, CancellationToken.None])); // 15 = MX Record
|
||||
waitTask.Wait();
|
||||
|
||||
object response = waitTask.Result;
|
||||
waitTask.Dispose();
|
||||
|
||||
int responseCode = (int)response.GetType().GetProperty("ResponseCode").GetValue(response);
|
||||
if (responseCode != 0)
|
||||
continue;
|
||||
|
||||
object list = response.GetType().GetProperty("AnswerRecords").GetValue(response);
|
||||
foreach (object item in (list as IEnumerable))
|
||||
{
|
||||
int type = (int)item.GetType().GetProperty("Type").GetValue(item);
|
||||
if (type == 15) // MX found
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a copy of the specified string followed by the specified line terminator
|
||||
/// to the end of the current <see cref="StringBuilder"/> object.
|
||||
/// </summary>
|
||||
/// <param name="sb">The <see cref="StringBuilder"/> object.</param>
|
||||
/// <param name="value">The string to append.</param>
|
||||
/// <param name="newLine">The line terminator.</param>
|
||||
/// <returns></returns>
|
||||
public static StringBuilder AppendLine(this StringBuilder sb, string value, string newLine)
|
||||
=> sb.Append(value).Append(newLine);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
[GeneratedRegex("[^0-9a-fA-F]", RegexOptions.Compiled)]
|
||||
private static partial Regex InvalidHexCharRegex();
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$", RegexOptions.Compiled)]
|
||||
private static partial Regex ValidEmailRegex();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
4
src/AMWD.Common/InternalsVisibleTo.cs
Normal file
4
src/AMWD.Common/InternalsVisibleTo.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("AMWD.Common.Tests")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
|
||||
327
src/AMWD.Common/Logging/FileLogger.cs
Normal file
327
src/AMWD.Common/Logging/FileLogger.cs
Normal file
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AMWD.Common.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a file logging based on the <see cref="ILogger"/> interface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation is also using the <see cref="IDisposable"/> interface!
|
||||
/// <br/>
|
||||
/// Inspired by <see href="https://github.com/aspnet/Logging/blob/2d2f31968229eddb57b6ba3d34696ef366a6c71b/src/Microsoft.Extensions.Logging.Console/ConsoleLogger.cs">ConsoleLogger.cs</see>
|
||||
/// </remarks>
|
||||
/// <seealso cref="ILogger" />
|
||||
/// <seealso cref="IDisposable" />
|
||||
public class FileLogger : ILogger, IDisposable
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private bool _isDisposed = false;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
private readonly StreamWriter _fileWriter;
|
||||
private readonly Task _writeTask;
|
||||
|
||||
private readonly AsyncQueue<QueueItem> _queue = new();
|
||||
|
||||
#endregion Fields
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="file">The log file.</param>
|
||||
/// <param name="append">A value indicating whether to append lines to an existing file (Default: <c>false</c>).</param>
|
||||
/// <param name="encoding">The file encoding (Default: <see cref="Encoding.UTF8"/>).</param>
|
||||
public FileLogger(string file, bool append = false, Encoding encoding = null)
|
||||
{
|
||||
FileName = file;
|
||||
_fileWriter = new StreamWriter(FileName, append, encoding ?? Encoding.UTF8);
|
||||
_writeTask = Task.Run(() => WriteFileAsync(_cancellationTokenSource.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new named instance of the <see cref="FileLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="file">The log file.</param>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="append">A value indicating whether to append lines to an existing file.</param>
|
||||
/// <param name="encoding">The file encoding.</param>
|
||||
public FileLogger(string file, string name, bool append = false, Encoding encoding = null)
|
||||
: this(file, append, encoding)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="file">The log file.</param>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="parentLogger">The parent logger.</param>
|
||||
/// <param name="append">A value indicating whether to append lines to an existing file.</param>
|
||||
/// <param name="encoding">The file encoding.</param>
|
||||
public FileLogger(string file, string name, FileLogger parentLogger, bool append = false, Encoding encoding = null)
|
||||
: this(file, name, append, encoding)
|
||||
{
|
||||
ParentLogger = parentLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="file">The log file.</param>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="scopeProvider">The scope provider.</param>
|
||||
/// <param name="append">A value indicating whether to append lines to an existing file.</param>
|
||||
/// <param name="encoding">The file encoding.</param>
|
||||
public FileLogger(string file, string name, IExternalScopeProvider scopeProvider, bool append = false, Encoding encoding = null)
|
||||
: this(file, name, append, encoding)
|
||||
{
|
||||
ScopeProvider = scopeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="file">The log file.</param>
|
||||
/// <param name="name">The logger name.</param>
|
||||
/// <param name="parentLogger">The parent logger.</param>
|
||||
/// <param name="scopeProvider">The scope provider.</param>
|
||||
/// <param name="append">A value indicating whether to append lines to an existing file.</param>
|
||||
/// <param name="encoding">The file encoding.</param>
|
||||
public FileLogger(string file, string name, FileLogger parentLogger, IExternalScopeProvider scopeProvider, bool append = false, Encoding encoding = null)
|
||||
: this(file, name, parentLogger, append, encoding)
|
||||
{
|
||||
ScopeProvider = scopeProvider;
|
||||
}
|
||||
|
||||
#endregion Constructors
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log file.
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the logger.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent logger.
|
||||
/// </summary>
|
||||
public FileLogger ParentLogger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp format.
|
||||
/// </summary>
|
||||
public string TimestampFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the timestamp is in UTC.
|
||||
/// </summary>
|
||||
public bool UseUtcTimestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum level to log.
|
||||
/// </summary>
|
||||
public LogLevel MinLevel { get; set; }
|
||||
|
||||
internal IExternalScopeProvider ScopeProvider { get; }
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region ILogger implementation
|
||||
|
||||
/// <inheritdoc cref="ILogger.BeginScope{TState}(TState)" />
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
throw new ObjectDisposedException(GetType().FullName);
|
||||
|
||||
return ScopeProvider?.Push(state) ?? NullScope.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ILogger.IsEnabled(LogLevel)" />
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
if (_isDisposed)
|
||||
throw new ObjectDisposedException(GetType().FullName);
|
||||
|
||||
return logLevel >= MinLevel;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ILogger.Log{TState}(LogLevel, EventId, TState, Exception, Func{TState, Exception, string})" />
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
|
||||
{
|
||||
if (_isDisposed)
|
||||
throw new ObjectDisposedException(GetType().FullName);
|
||||
|
||||
if (!IsEnabled(logLevel))
|
||||
return;
|
||||
|
||||
if (formatter == null)
|
||||
throw new ArgumentNullException(nameof(formatter));
|
||||
|
||||
string message = formatter(state, exception);
|
||||
|
||||
if (!string.IsNullOrEmpty(message) || exception != null)
|
||||
{
|
||||
if (ParentLogger == null)
|
||||
{
|
||||
WriteMessage(Name, logLevel, eventId.Id, message, exception);
|
||||
}
|
||||
else
|
||||
{
|
||||
ParentLogger.WriteMessage(Name, logLevel, eventId.Id, message, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion ILogger implementation
|
||||
|
||||
#region IDisposable implementation
|
||||
|
||||
/// <inheritdoc cref="IDisposable.Dispose" />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
|
||||
_cancellationTokenSource.Cancel();
|
||||
_writeTask.GetAwaiter().GetResult();
|
||||
_fileWriter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion IDisposable implementation
|
||||
|
||||
#region Private methods
|
||||
|
||||
private void WriteMessage(string name, LogLevel logLevel, int eventId, string message, Exception exception)
|
||||
{
|
||||
_queue.Enqueue(new QueueItem
|
||||
{
|
||||
Timestamp = UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now,
|
||||
Name = name,
|
||||
LogLevel = logLevel,
|
||||
EventId = eventId,
|
||||
Message = message,
|
||||
Exception = exception
|
||||
});
|
||||
}
|
||||
|
||||
private async Task WriteFileAsync(CancellationToken token)
|
||||
{
|
||||
string timestampPadding = "";
|
||||
string logLevelPadding = new(' ', 7);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
QueueItem[] items;
|
||||
try
|
||||
{
|
||||
items = await _queue.DequeueAvailableAsync(cancellationToken: token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
sb.Clear();
|
||||
|
||||
string timestamp = "";
|
||||
string message = item.Message;
|
||||
|
||||
if (!string.IsNullOrEmpty(TimestampFormat))
|
||||
{
|
||||
timestamp = item.Timestamp.ToString(TimestampFormat) + " | ";
|
||||
sb.Append(timestamp);
|
||||
timestampPadding = new string(' ', timestamp.Length);
|
||||
}
|
||||
|
||||
string logLevel = item.LogLevel switch
|
||||
{
|
||||
LogLevel.Trace => "TRCE | ",
|
||||
LogLevel.Debug => "DBUG | ",
|
||||
LogLevel.Information => "INFO | ",
|
||||
LogLevel.Warning => "WARN | ",
|
||||
LogLevel.Error => "FAIL | ",
|
||||
LogLevel.Critical => "CRIT | ",
|
||||
_ => " | ",
|
||||
};
|
||||
sb.Append(logLevel);
|
||||
logLevelPadding = new string(' ', logLevel.Length);
|
||||
|
||||
if (ScopeProvider != null)
|
||||
{
|
||||
int initLength = sb.Length;
|
||||
|
||||
ScopeProvider.ForEachScope((scope, state) =>
|
||||
{
|
||||
var (builder, length) = state;
|
||||
bool first = length == builder.Length;
|
||||
builder.Append(first ? "=>" : " => ").Append(scope);
|
||||
}, (sb, initLength));
|
||||
|
||||
if (sb.Length > initLength)
|
||||
sb.Insert(initLength, timestampPadding + logLevelPadding);
|
||||
}
|
||||
|
||||
if (item.Exception != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
message += Environment.NewLine + item.Exception.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
message = item.Exception.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.Name))
|
||||
sb.Append($"[{item.Name}] ");
|
||||
|
||||
sb.Append(message.Replace("\n", "\n" + timestampPadding + logLevelPadding));
|
||||
|
||||
await _fileWriter.WriteLineAsync(sb.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _fileWriter.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Private methods
|
||||
|
||||
private class QueueItem
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public LogLevel LogLevel { get; set; }
|
||||
|
||||
public EventId EventId { get; set; }
|
||||
|
||||
public string Message { get; set; }
|
||||
|
||||
public Exception Exception { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/AMWD.Common/Logging/NullScope.cs
Normal file
25
src/AMWD.Common/Logging/NullScope.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace AMWD.Common.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty scope without any logic.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class NullScope : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="NullScope"/> instance to use.
|
||||
/// </summary>
|
||||
public static NullScope Instance { get; } = new NullScope();
|
||||
|
||||
private NullScope()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/AMWD.Common/Packing/Ar/ArFileInfo.cs
Normal file
51
src/AMWD.Common/Packing/Ar/ArFileInfo.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace AMWD.Common.Packing.Ar
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the file information saved in the archive.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class ArFileInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the file name.
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp of the last modification.
|
||||
/// </summary>
|
||||
public DateTime ModifyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user id.
|
||||
/// </summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the group id.
|
||||
/// </summary>
|
||||
public int GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the access mode in decimal (not octal!).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// To see the octal representation use <c>Convert.ToString(Mode, 8)</c>.
|
||||
/// </remarks>
|
||||
public int Mode { get; set; }
|
||||
}
|
||||
|
||||
internal class ArFileInfoExtended : ArFileInfo
|
||||
{
|
||||
public long HeaderPosition { get; set; }
|
||||
|
||||
public long DataPosition { get; set; }
|
||||
}
|
||||
}
|
||||
176
src/AMWD.Common/Packing/Ar/ArReader.cs
Normal file
176
src/AMWD.Common/Packing/Ar/ArReader.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AMWD.Common.Packing.Ar
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads UNIX ar (archive) files in the GNU format.
|
||||
/// </summary>
|
||||
public class ArReader
|
||||
{
|
||||
// Source: http://en.wikipedia.org/wiki/Ar_%28Unix%29
|
||||
|
||||
private readonly Stream _inStream;
|
||||
private readonly List<ArFileInfoExtended> _files = [];
|
||||
private readonly long _streamStartPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ArReader"/> class.
|
||||
/// </summary>
|
||||
/// <param name="inStream">The stream to read the archive from.</param>
|
||||
public ArReader(Stream inStream)
|
||||
{
|
||||
if (!inStream.CanRead || !inStream.CanSeek)
|
||||
throw new ArgumentException("Stream not readable or seekable", nameof(inStream));
|
||||
|
||||
_streamStartPosition = inStream.Position;
|
||||
_inStream = inStream;
|
||||
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list with all filenames of the archive.
|
||||
/// </summary>
|
||||
public IEnumerable<string> GetFileList()
|
||||
=> _files.Select(fi => fi.FileName).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file info of a specific file in the archive.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the specific file.</param>
|
||||
public ArFileInfo GetFileInfo(string fileName)
|
||||
{
|
||||
return _files
|
||||
.Where(fi => fi.FileName == fileName)
|
||||
.Select(fi => new ArFileInfo
|
||||
{
|
||||
FileName = fi.FileName,
|
||||
FileSize = fi.FileSize,
|
||||
GroupId = fi.GroupId,
|
||||
Mode = fi.Mode,
|
||||
ModifyTime = fi.ModifyTime,
|
||||
UserId = fi.UserId
|
||||
})
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from the archive into a stream.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name in the archive.</param>
|
||||
/// <param name="outStream">The output stream.</param>
|
||||
public void ReadFile(string fileName, Stream outStream)
|
||||
{
|
||||
if (!outStream.CanWrite)
|
||||
throw new ArgumentException("Stream not writable", nameof(outStream));
|
||||
|
||||
var info = _files.Where(fi => fi.FileName == fileName).FirstOrDefault();
|
||||
if (info == null)
|
||||
return;
|
||||
|
||||
long bytesToRead = info.FileSize;
|
||||
byte[] buffer = new byte[1024 * 1024];
|
||||
|
||||
_inStream.Seek(info.DataPosition, SeekOrigin.Begin);
|
||||
while (bytesToRead > 0)
|
||||
{
|
||||
int readCount = (int)Math.Min(bytesToRead, buffer.Length);
|
||||
_inStream.Read(buffer, 0, readCount);
|
||||
outStream.Write(buffer, 0, readCount);
|
||||
|
||||
bytesToRead -= readCount;
|
||||
}
|
||||
_inStream.Seek(_streamStartPosition, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fie from the archive and saves it to disk.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name in the archive.</param>
|
||||
/// <param name="destinationPath">The destination path on disk.</param>
|
||||
public void ReadFile(string fileName, string destinationPath)
|
||||
{
|
||||
var info = _files.Where(fi => fi.FileName == fileName).FirstOrDefault();
|
||||
if (info == null)
|
||||
return;
|
||||
|
||||
using (var fs = File.OpenWrite(destinationPath))
|
||||
{
|
||||
ReadFile(fileName, fs);
|
||||
}
|
||||
File.SetLastWriteTimeUtc(destinationPath, info.ModifyTime);
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
// Reset stream
|
||||
_inStream.Seek(_streamStartPosition, SeekOrigin.Begin);
|
||||
|
||||
// Read header
|
||||
string header = ReadAsciiString(8);
|
||||
if (header != "!<arch>\n")
|
||||
throw new FormatException("The file stream is no archive");
|
||||
|
||||
// Create file list
|
||||
while (_inStream.Position < _inStream.Length)
|
||||
{
|
||||
var info = ReadFileHeader();
|
||||
_files.Add(info);
|
||||
|
||||
// Move stream behind file content
|
||||
_inStream.Seek(info.FileSize, SeekOrigin.Current);
|
||||
|
||||
// Align to even offsets (padded with LF bytes)
|
||||
if (_inStream.Position % 2 != 0)
|
||||
_inStream.Seek(1, SeekOrigin.Current);
|
||||
}
|
||||
|
||||
// Reset stream
|
||||
_inStream.Seek(_streamStartPosition, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
private string ReadAsciiString(int byteCount)
|
||||
{
|
||||
byte[] buffer = new byte[byteCount];
|
||||
_inStream.Read(buffer, 0, byteCount);
|
||||
return Encoding.ASCII.GetString(buffer);
|
||||
}
|
||||
|
||||
private ArFileInfoExtended ReadFileHeader()
|
||||
{
|
||||
long startPosition = _inStream.Position;
|
||||
|
||||
string fileName = ReadAsciiString(16).Trim();
|
||||
|
||||
int.TryParse(ReadAsciiString(12).Trim(), out int unixTimestamp);
|
||||
int.TryParse(ReadAsciiString(6).Trim(), out int userId);
|
||||
int.TryParse(ReadAsciiString(6).Trim(), out int groupId);
|
||||
int mode = Convert.ToInt32(ReadAsciiString(8).Trim(), 8);
|
||||
|
||||
long.TryParse(ReadAsciiString(10).Trim(), out long fileSize);
|
||||
|
||||
// file magic
|
||||
byte[] magic = new byte[2];
|
||||
_inStream.Read(magic, 0, magic.Length);
|
||||
|
||||
if (magic[0] != 0x60 || magic[1] != 0x0A) // `\n
|
||||
throw new FormatException("Invalid file magic");
|
||||
|
||||
return new ArFileInfoExtended
|
||||
{
|
||||
HeaderPosition = startPosition,
|
||||
DataPosition = _inStream.Position,
|
||||
FileName = fileName,
|
||||
ModifyTime = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).DateTime,
|
||||
UserId = userId,
|
||||
GroupId = groupId,
|
||||
Mode = mode,
|
||||
FileSize = fileSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/AMWD.Common/Packing/Ar/ArWriter.cs
Normal file
146
src/AMWD.Common/Packing/Ar/ArWriter.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AMWD.Common.Packing.Ar
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes UNIX ar (archive) files in the GNU format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Copied from: <see href="https://github.com/ygoe/DotnetMakeDeb/blob/v1.1.0/DotnetMakeDeb/Ar/ArWriter.cs">DotnetMakeDeb</see>
|
||||
/// </remarks>
|
||||
public class ArWriter
|
||||
{
|
||||
// Source: http://en.wikipedia.org/wiki/Ar_%28Unix%29
|
||||
|
||||
private readonly Stream _outStream;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="ArWriter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outStream">The stream to write the archive to.</param>
|
||||
public ArWriter(Stream outStream)
|
||||
{
|
||||
if (!outStream.CanWrite)
|
||||
throw new ArgumentException("Stream not writable", nameof(outStream));
|
||||
|
||||
_outStream = outStream;
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file from disk to the archive.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to copy.</param>
|
||||
/// <param name="userId">The user ID of the file in the archive.</param>
|
||||
/// <param name="groupId">The group ID of the file in the archive.</param>
|
||||
/// <param name="mode">The mode of the file in the archive (decimal).</param>
|
||||
public void WriteFile(string fileName, int userId = 0, int groupId = 0, int mode = 33188 /* 0100644 */)
|
||||
{
|
||||
var fi = new FileInfo(fileName);
|
||||
|
||||
using var fs = File.OpenRead(fileName);
|
||||
WriteFile(fs, fi.Name, fi.LastWriteTimeUtc, userId, groupId, mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file from a Stream to the archive.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to read the file contents from.</param>
|
||||
/// <param name="fileName">The name of the file in the archive.</param>
|
||||
/// <param name="modifyTime">The last modification time of the file in the archive.</param>
|
||||
/// <param name="userId">The user ID of the file in the archive.</param>
|
||||
/// <param name="groupId">The group ID of the file in the archive.</param>
|
||||
/// <param name="mode">The mode of the file in the archive (decimal).</param>
|
||||
public void WriteFile(Stream stream, string fileName, DateTime modifyTime, int userId = 0, int groupId = 0, int mode = 33188 /* 0100644 */)
|
||||
{
|
||||
// Write file header
|
||||
WriteFileHeader(fileName, modifyTime, userId, groupId, mode, stream.Length);
|
||||
|
||||
// Write file contents
|
||||
stream.CopyTo(_outStream);
|
||||
|
||||
// Align to even offsets, pad with LF bytes
|
||||
if ((_outStream.Position % 2) != 0)
|
||||
{
|
||||
byte[] bytes = [0x0A];
|
||||
_outStream.Write(bytes, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the archive header.
|
||||
/// </summary>
|
||||
private void Initialize()
|
||||
{
|
||||
WriteAsciiString("!<arch>\n");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file header.
|
||||
/// </summary>
|
||||
private void WriteFileHeader(string fileName, DateTime modifyTime, int userId, int groupId, int mode, long fileSize)
|
||||
{
|
||||
// File name
|
||||
if (fileName.Length > 16)
|
||||
throw new ArgumentException("Long file names are not supported.");
|
||||
|
||||
WriteAsciiString(fileName.PadRight(16, ' '));
|
||||
|
||||
// File modification timestamp
|
||||
long unixTime = ((DateTimeOffset)DateTime.SpecifyKind(modifyTime, DateTimeKind.Utc)).ToUnixTimeSeconds();
|
||||
WriteAsciiString(unixTime.ToString().PadRight(12, ' '));
|
||||
|
||||
// User ID
|
||||
if (userId >= 0)
|
||||
{
|
||||
WriteAsciiString(userId.ToString().PadRight(6, ' '));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteAsciiString(" ");
|
||||
}
|
||||
|
||||
// Group ID
|
||||
if (groupId >= 0)
|
||||
{
|
||||
WriteAsciiString(groupId.ToString().PadRight(6, ' '));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteAsciiString(" ");
|
||||
}
|
||||
|
||||
// File mode
|
||||
if (mode >= 0)
|
||||
{
|
||||
WriteAsciiString(Convert.ToString(mode, 8).PadRight(8, ' '));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteAsciiString(" ");
|
||||
}
|
||||
|
||||
// File size in bytes
|
||||
if (fileSize < 0 || 10000000000 <= fileSize)
|
||||
throw new ArgumentOutOfRangeException(nameof(fileSize), "Invalid file size."); // above 9.32 GB
|
||||
|
||||
WriteAsciiString(fileSize.ToString().PadRight(10, ' '));
|
||||
|
||||
// File magic
|
||||
byte[] bytes = [0x60, 0x0A];
|
||||
_outStream.Write(bytes, 0, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a string using ASCII encoding.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to write to the output stream.</param>
|
||||
private void WriteAsciiString(string str)
|
||||
{
|
||||
byte[] bytes = Encoding.ASCII.GetBytes(str);
|
||||
_outStream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
437
src/AMWD.Common/Utilities/AsyncQueue.cs
Normal file
437
src/AMWD.Common/Utilities/AsyncQueue.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace System.Collections.Generic
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a first-in, first-out collection of objects.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Specifies the type of elements in the queue.</typeparam>
|
||||
public class AsyncQueue<T>
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private readonly Queue<T> _queue;
|
||||
|
||||
private TaskCompletionSource<bool> _dequeueTcs = new();
|
||||
private TaskCompletionSource<bool> _availableTcs = new();
|
||||
|
||||
#endregion Fields
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AsyncQueue{T}"/> class that is empty and has the default initial capacity.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public AsyncQueue()
|
||||
{
|
||||
_queue = new Queue<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the System.Collections.Generic.Queue`1 class that
|
||||
/// contains elements copied from the specified collection and has sufficient capacity
|
||||
/// to accommodate the number of elements copied.
|
||||
/// </summary>
|
||||
/// <param name="collection">The collection whose elements are copied to the new <see cref="AsyncQueue{T}"/>.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public AsyncQueue(IEnumerable<T> collection)
|
||||
{
|
||||
_queue = new Queue<T>();
|
||||
Enqueue(collection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AsyncQueue{T}"/> class that is empty and has the specified initial capacity.
|
||||
/// </summary>
|
||||
/// <param name="capacity">The initial number of elements that the <see cref="AsyncQueue{T}"/> can contain.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="capacity"/> is less than zero.</exception>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public AsyncQueue(int capacity)
|
||||
{
|
||||
_queue = new Queue<T>(capacity);
|
||||
}
|
||||
|
||||
#endregion Constructors
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of elements contained in the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
return _queue.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Queue implementation
|
||||
|
||||
/// <summary>
|
||||
/// Removes all objects from the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public void Clear()
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
_queue.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an element is in the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="item">The object to locate in the <see cref="AsyncQueue{T}"/>. The value can be null for reference types.</param>
|
||||
/// <returns><see langword="true"/> if item is found in the <see cref="AsyncQueue{T}"/>, otherwise <see langword="false"/>.</returns>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public bool Contains(T item)
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
return _queue.Contains(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the <see cref="AsyncQueue{T}"/> elements to an existing one-dimensional <see cref="Array"/>, starting at the specified array index.
|
||||
/// </summary>
|
||||
/// <param name="array">The one-dimensional <see cref="Array"/> that is the destination of the elements copied from <see cref="AsyncQueue{T}"/>. The <see cref="Array"/> must have zero-based indexing.</param>
|
||||
/// <param name="arrayIndex">The zero-based index in array at which copying begins.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="array"/> is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="arrayIndex"/> is less than zero.</exception>
|
||||
/// <exception cref="ArgumentException">The number of elements in the source <see cref="AsyncQueue{T}"/> is greater than the available space from <paramref name="arrayIndex"/> to the end of the destination array.</exception>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public void CopyTo(T[] array, int arrayIndex)
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
_queue.CopyTo(array, arrayIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns the object at the beginning of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <returns>The object that is removed from the beginning of the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">The <see cref="AsyncQueue{T}"/> is empty.</exception>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public T Dequeue()
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
return _queue.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an object to the end of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="item">The object to add to the <see cref="AsyncQueue{T}"/>. The value can be null for reference types.</param>
|
||||
public void Enqueue(T item)
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
_queue.Enqueue(item);
|
||||
SetToken(_dequeueTcs);
|
||||
SetToken(_availableTcs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the object at the beginning of the <see cref="AsyncQueue{T}"/> without removing it.
|
||||
/// </summary>
|
||||
/// <returns>The object at the beginning of the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">The <see cref="AsyncQueue{T}"/> is empty.</exception>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public T Peek()
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
return _queue.Peek();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the <see cref="AsyncQueue{T}"/> elements to a new array.
|
||||
/// </summary>
|
||||
/// <returns>A new array containing elements copied from the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public T[] ToArray()
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
return [.. _queue];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the capacity to the actual number of elements in the <see cref="AsyncQueue{T}"/>, if that number is less than 90 percent of current capacity.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public void TrimExcess()
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
_queue.TrimExcess();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Queue implementation
|
||||
|
||||
#region Async implementation
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns all available objects at the beginning of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="maxCount">The maximum number of objects to return. Zero means no limit.</param>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
/// <returns>The objects that are removed from the beginning of the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
public async Task<T[]> DequeueAvailableAsync(int maxCount = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TaskCompletionSource<bool> internalDequeueTcs;
|
||||
lock (_queue)
|
||||
{
|
||||
if (_queue.Count > 0)
|
||||
{
|
||||
int count = _queue.Count;
|
||||
if (maxCount > 0 && count > maxCount)
|
||||
count = maxCount;
|
||||
|
||||
var items = new T[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
items[i] = _queue.Dequeue();
|
||||
|
||||
return items;
|
||||
}
|
||||
internalDequeueTcs = ResetToken(ref _dequeueTcs);
|
||||
}
|
||||
|
||||
await WaitAsync(internalDequeueTcs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns objects at the beginning of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="count">The number of objects to return.</param>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
/// <returns>The objects that are removed from the beginning of the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
public async Task<T[]> DequeueManyAsync(int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
|
||||
while (true)
|
||||
{
|
||||
TaskCompletionSource<bool> internalDequeueTcs;
|
||||
lock (_queue)
|
||||
{
|
||||
if (count <= _queue.Count)
|
||||
{
|
||||
var items = new T[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
items[i] = _queue.Dequeue();
|
||||
|
||||
return items;
|
||||
}
|
||||
internalDequeueTcs = ResetToken(ref _dequeueTcs);
|
||||
}
|
||||
|
||||
await WaitAsync(internalDequeueTcs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns the object at the beginning of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
/// <returns>The object that is removed from the beginning of the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
public async Task<T> DequeueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TaskCompletionSource<bool> internalDequeueTcs;
|
||||
lock (_queue)
|
||||
{
|
||||
if (_queue.Count > 0)
|
||||
return _queue.Dequeue();
|
||||
|
||||
internalDequeueTcs = ResetToken(ref _dequeueTcs);
|
||||
}
|
||||
|
||||
await WaitAsync(internalDequeueTcs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits asynchonously until at least one object is available in the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
/// <returns>An awaitable task.</returns>
|
||||
public async Task WaitAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
TaskCompletionSource<bool> internalAvailableTcs;
|
||||
lock (_queue)
|
||||
{
|
||||
if (_queue.Count > 0)
|
||||
return;
|
||||
|
||||
internalAvailableTcs = ResetToken(ref _availableTcs);
|
||||
}
|
||||
|
||||
await WaitAsync(internalAvailableTcs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion Async implementation
|
||||
|
||||
#region Additional features
|
||||
|
||||
/// <summary>
|
||||
/// Removes the object at the beginning of the <see cref="AsyncQueue{T}"/>, and copies it to the <paramref name="result"/> parameter.
|
||||
/// </summary>
|
||||
/// <param name="result">The removed object.</param>
|
||||
/// <returns><see langword="true"/> if the object is successfully removed, <see langword="false"/> if the <see cref="AsyncQueue{T}"/> is empty.</returns>
|
||||
public bool TryDequeue(out T result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Dequeue();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value that indicates whether there is an object at the beginning of
|
||||
/// the <see cref="AsyncQueue{T}"/>, and if one is present, copies it to the
|
||||
/// <paramref name="result"/> parameter. The object is not removed from the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="result">If present, the object at the beginning of the <see cref="AsyncQueue{T}"/>; otherwise, the default value of <typeparamref name="T"/>.</param>
|
||||
/// <returns><see langword="true"/> if there is an object at the beginning of the <see cref="AsyncQueue{T}"/>, <see langword="false"/> if the <see cref="AsyncQueue{T}"/> is empty.</returns>
|
||||
public bool TryPeek(out T result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Peek();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the first occurrence of a specific object from the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="item">The object to remove from the <see cref="AsyncQueue{T}"/>. The value can be null for reference types.</param>
|
||||
/// <returns><see langword="true"/> if item is successfully removed, otherwise <see langword="false"/>. This method also returns <see langword="false"/> if item was not found in the <see cref="AsyncQueue{T}"/>.</returns>
|
||||
public bool Remove(T item)
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
var copy = new Queue<T>(_queue);
|
||||
_queue.Clear();
|
||||
|
||||
bool found = false;
|
||||
int count = copy.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var element = copy.Dequeue();
|
||||
if (found)
|
||||
{
|
||||
_queue.Enqueue(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((element == null && item == null) || element?.Equals(item) == true)
|
||||
{
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_queue.Enqueue(element);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds objects to the end of the <see cref="AsyncQueue{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="collection">The objects to add to the <see cref="AsyncQueue{T}"/>.</param>
|
||||
public void Enqueue(IEnumerable<T> collection)
|
||||
{
|
||||
lock (_queue)
|
||||
{
|
||||
bool hasElements = false;
|
||||
foreach (var element in collection)
|
||||
{
|
||||
hasElements = true;
|
||||
_queue.Enqueue(element);
|
||||
}
|
||||
|
||||
if (hasElements)
|
||||
{
|
||||
SetToken(_dequeueTcs);
|
||||
SetToken(_availableTcs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Additional features
|
||||
|
||||
#region Helper
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
private static void SetToken(TaskCompletionSource<bool> tcs)
|
||||
{
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
private static TaskCompletionSource<bool> ResetToken(ref TaskCompletionSource<bool> tcs)
|
||||
{
|
||||
if (tcs.Task.IsCompleted)
|
||||
{
|
||||
tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
return tcs;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
private static async Task WaitAsync(TaskCompletionSource<bool> tcs, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await Task.WhenAny(tcs.Task, Task.Delay(-1, cancellationToken)) == tcs.Task)
|
||||
{
|
||||
await tcs.Task.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
#endregion Helper
|
||||
}
|
||||
}
|
||||
626
src/AMWD.Common/Utilities/CryptographyHelper.cs
Normal file
626
src/AMWD.Common/Utilities/CryptographyHelper.cs
Normal file
@@ -0,0 +1,626 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace System.Security.Cryptography
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides cryptographic functions ready-to-use.
|
||||
/// </summary>
|
||||
public class CryptographyHelper
|
||||
{
|
||||
// "readonly" not added due to UnitTests
|
||||
#pragma warning disable IDE0044 // Add "readonly" modifier
|
||||
private static int _saltLength = 8;
|
||||
#pragma warning restore IDE0044 // Add "readonly" modifier
|
||||
|
||||
private readonly string _masterKeyFile;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CryptographyHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="keyFile">The (absolute) path to the crypto key file. On <c>null</c> the file 'crypto.key' at the executing assembly location will be used.</param>
|
||||
public CryptographyHelper(string keyFile = null)
|
||||
{
|
||||
_masterKeyFile = keyFile;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_masterKeyFile))
|
||||
_masterKeyFile = "crypto.key";
|
||||
|
||||
if (!Path.IsPathRooted(_masterKeyFile))
|
||||
_masterKeyFile = Path.Combine(AppContext.BaseDirectory, _masterKeyFile);
|
||||
|
||||
string pw = File.Exists(_masterKeyFile) ? File.ReadAllText(_masterKeyFile) : null;
|
||||
if (string.IsNullOrWhiteSpace(pw))
|
||||
File.WriteAllText(_masterKeyFile, GetRandomString(64));
|
||||
}
|
||||
|
||||
#region Instance methods
|
||||
|
||||
#region AES
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts data using the AES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="cipher">The encrypted data (cipher).</param>
|
||||
/// <param name="password">The password to use for decryption (optional).</param>
|
||||
/// <returns>The decrypted data.</returns>
|
||||
public byte[] DecryptAes(byte[] cipher, string password = null)
|
||||
{
|
||||
password ??= File.ReadAllText(_masterKeyFile);
|
||||
|
||||
return AesDecrypt(cipher, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data using the AES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="plain">The data to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption (optional).</param>
|
||||
/// <returns>The encrypted data (cipher).</returns>
|
||||
public byte[] EncryptAes(byte[] plain, string password = null)
|
||||
{
|
||||
password ??= File.ReadAllText(_masterKeyFile);
|
||||
|
||||
return AesEncrypt(plain, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Base64 string using the AES algorithm and a password to an UTF-8 string.
|
||||
/// </summary>
|
||||
/// <param name="cipherStr">The encrypted Base64 encoded string.</param>
|
||||
/// <param name="password">The password to use for decryption (optional).</param>
|
||||
/// <returns>The decrypted UTF-8 string string.</returns>
|
||||
public string DecryptAes(string cipherStr, string password = null)
|
||||
{
|
||||
byte[] cipher = Convert.FromBase64String(cipherStr);
|
||||
byte[] plain = DecryptAes(cipher, password);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts an UTF-8 string using the AES algorithm and a password to a Base64 string.
|
||||
/// </summary>
|
||||
/// <param name="plainStr">The UTF-8 string to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption (optional).</param>
|
||||
/// <returns>The encrypted Base64 encoded string.</returns>
|
||||
public string EncryptAes(string plainStr, string password = null)
|
||||
{
|
||||
byte[] plain = Encoding.UTF8.GetBytes(plainStr);
|
||||
byte[] cipher = EncryptAes(plain, password);
|
||||
return Convert.ToBase64String(cipher);
|
||||
}
|
||||
|
||||
#endregion AES
|
||||
|
||||
#region Triple DES
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts data using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="cipher">The encrypted data (cipher).</param>
|
||||
/// <param name="password">The password to use for decryption (optional).</param>
|
||||
/// <returns>The decrypted data.</returns>
|
||||
public byte[] DecryptTripleDes(byte[] cipher, string password = null)
|
||||
{
|
||||
password ??= File.ReadAllText(_masterKeyFile);
|
||||
|
||||
return TripleDesDecrypt(cipher, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="plain">The data to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption (optional).</param>
|
||||
/// <returns>The encrypted data (cipher).</returns>
|
||||
public byte[] EncryptTripleDes(byte[] plain, string password = null)
|
||||
{
|
||||
password ??= File.ReadAllText(_masterKeyFile);
|
||||
|
||||
return TripleDesEncrypt(plain, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Base64 encoded string using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="cipherStr">The encrypted Base64 encoded string.</param>
|
||||
/// <param name="password">The password to use for decryption (optional).</param>
|
||||
/// <returns>The decrypted UTF-8 string.</returns>
|
||||
public string DecryptTripleDes(string cipherStr, string password = null)
|
||||
{
|
||||
byte[] cipher = Convert.FromBase64String(cipherStr);
|
||||
byte[] plain = DecryptTripleDes(cipher, password);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts an UTF-8 string using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the <paramref name="password"/> parameter is <c>null</c>, the key from the file (set on initialize) is used instead.
|
||||
/// </remarks>
|
||||
/// <param name="plainStr">The UTF-8 string to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption (optional).</param>
|
||||
/// <returns>The encrypted Base64 encoded string.</returns>
|
||||
public string EncryptTripleDes(string plainStr, string password = null)
|
||||
{
|
||||
byte[] plain = Encoding.UTF8.GetBytes(plainStr);
|
||||
byte[] cipher = EncryptTripleDes(plain, password);
|
||||
return Convert.ToBase64String(cipher);
|
||||
}
|
||||
|
||||
#endregion Triple DES
|
||||
|
||||
#endregion Instance methods
|
||||
|
||||
#region Static methods
|
||||
|
||||
#region Encryption
|
||||
#pragma warning disable SYSLIB0041
|
||||
|
||||
#region AES
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts data using the AES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="cipher">The encrypted data (cipher).</param>
|
||||
/// <param name="password">The password to use for decryption.</param>
|
||||
/// <returns>The decrypted data.</returns>
|
||||
public static byte[] AesDecrypt(byte[] cipher, string password)
|
||||
{
|
||||
byte[] salt = new byte[_saltLength];
|
||||
Array.Copy(cipher, salt, _saltLength);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000, HashAlgorithmName.SHA1);
|
||||
#else
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000);
|
||||
#endif
|
||||
using var aes = Aes.Create();
|
||||
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = gen.GetBytes(aes.KeySize / 8);
|
||||
aes.IV = gen.GetBytes(aes.BlockSize / 8);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Write);
|
||||
|
||||
cs.Write(cipher, _saltLength, cipher.Length - _saltLength);
|
||||
cs.FlushFinalBlock();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Base64 string using the AES algorithm and a password to an UTF-8 string.
|
||||
/// </summary>
|
||||
/// <param name="cipherStr">The encrypted Base64 encoded string.</param>
|
||||
/// <param name="password">The password to use for decryption.</param>
|
||||
/// <returns>The decrypted UTF-8 string string.</returns>
|
||||
public static string AesDecrypt(string cipherStr, string password)
|
||||
{
|
||||
byte[] cipher = Convert.FromBase64String(cipherStr);
|
||||
byte[] plain = AesDecrypt(cipher, password);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data using the AES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="plain">The data to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption.</param>
|
||||
/// <returns>The encrypted data (cipher).</returns>
|
||||
public static byte[] AesEncrypt(byte[] plain, string password)
|
||||
{
|
||||
byte[] salt = GetRandomBytes(_saltLength);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000, HashAlgorithmName.SHA1);
|
||||
#else
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000);
|
||||
#endif
|
||||
using var aes = Aes.Create();
|
||||
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = gen.GetBytes(aes.KeySize / 8);
|
||||
aes.IV = gen.GetBytes(aes.BlockSize / 8);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write);
|
||||
|
||||
ms.Write(salt, 0, salt.Length);
|
||||
cs.Write(plain, 0, plain.Length);
|
||||
cs.FlushFinalBlock();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts an UTF-8 string using the AES algorithm and a password to a Base64 string.
|
||||
/// </summary>
|
||||
/// <param name="plainStr">The UTF-8 string to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption.</param>
|
||||
/// <returns>The encrypted Base64 encoded string.</returns>
|
||||
public static string AesEncrypt(string plainStr, string password)
|
||||
{
|
||||
byte[] plain = Encoding.UTF8.GetBytes(plainStr);
|
||||
byte[] cipher = AesEncrypt(plain, password);
|
||||
return Convert.ToBase64String(cipher);
|
||||
}
|
||||
|
||||
#endregion AES
|
||||
|
||||
#region Triple DES
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts data using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="cipher">The encrypted data (cipher).</param>
|
||||
/// <param name="password">The password to use for decryption.</param>
|
||||
/// <returns>The decrypted data.</returns>
|
||||
public static byte[] TripleDesDecrypt(byte[] cipher, string password)
|
||||
{
|
||||
byte[] salt = new byte[_saltLength];
|
||||
Array.Copy(cipher, salt, _saltLength);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000, HashAlgorithmName.SHA1);
|
||||
#else
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000);
|
||||
#endif
|
||||
using var tdes = TripleDES.Create();
|
||||
|
||||
tdes.Mode = CipherMode.CBC;
|
||||
tdes.Padding = PaddingMode.PKCS7;
|
||||
tdes.Key = gen.GetBytes(tdes.KeySize / 8);
|
||||
tdes.IV = gen.GetBytes(tdes.BlockSize / 8);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var cs = new CryptoStream(ms, tdes.CreateDecryptor(), CryptoStreamMode.Write);
|
||||
|
||||
cs.Write(cipher, _saltLength, cipher.Length - _saltLength);
|
||||
cs.FlushFinalBlock();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="plain">The data to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption.</param>
|
||||
/// <returns>The encrypted data (cipher).</returns>
|
||||
public static byte[] TripleDesEncrypt(byte[] plain, string password)
|
||||
{
|
||||
byte[] salt = GetRandomBytes(_saltLength);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000, HashAlgorithmName.SHA1);
|
||||
#else
|
||||
using var gen = new Rfc2898DeriveBytes(password, salt, 1000);
|
||||
#endif
|
||||
using var tdes = TripleDES.Create();
|
||||
|
||||
tdes.Mode = CipherMode.CBC;
|
||||
tdes.Padding = PaddingMode.PKCS7;
|
||||
tdes.Key = gen.GetBytes(tdes.KeySize / 8);
|
||||
tdes.IV = gen.GetBytes(tdes.BlockSize / 8);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var cs = new CryptoStream(ms, tdes.CreateEncryptor(), CryptoStreamMode.Write);
|
||||
|
||||
ms.Write(salt, 0, salt.Length);
|
||||
cs.Write(plain, 0, plain.Length);
|
||||
cs.FlushFinalBlock();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an Base64 encoded string using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="cipherStr">The encrypted Base64 encoded string.</param>
|
||||
/// <param name="password">The password to use for decryption.</param>
|
||||
/// <returns>The decrypted UTF-8 string.</returns>
|
||||
public static string TripleDesDecrypt(string cipherStr, string password)
|
||||
{
|
||||
byte[] cipher = Convert.FromBase64String(cipherStr);
|
||||
byte[] plain = TripleDesDecrypt(cipher, password);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts an UTF-8 string using the triple DES algorithm and a password.
|
||||
/// </summary>
|
||||
/// <param name="plainStr">The UTF-8 string to encrypt.</param>
|
||||
/// <param name="password">The password to use for encryption.</param>
|
||||
/// <returns>The encrypted Base64 encoded string.</returns>
|
||||
public static string TripleDesEncrypt(string plainStr, string password)
|
||||
{
|
||||
byte[] plain = Encoding.UTF8.GetBytes(plainStr);
|
||||
byte[] cipher = TripleDesEncrypt(plain, password);
|
||||
return Convert.ToBase64String(cipher);
|
||||
}
|
||||
|
||||
#endregion Triple DES
|
||||
|
||||
#pragma warning restore SYSLIB0041
|
||||
#endregion Encryption
|
||||
|
||||
#region Hashing
|
||||
|
||||
#region MD5
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the MD5 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The MD5 hash value, in hexadecimal notation.</returns>
|
||||
public static string Md5(string str)
|
||||
{
|
||||
return Md5(Encoding.UTF8.GetBytes(str));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a file using the MD5 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to read.</param>
|
||||
/// <returns>The MD5 hash value, in hexadecimal notation.</returns>
|
||||
public static string Md5File(string fileName)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
using var fs = new FileStream(fileName, FileMode.Open);
|
||||
return md5.ComputeHash(fs).BytesToHex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the MD5 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The MD5 hash value, in hexadecimal notation.</returns>
|
||||
public static string Md5(byte[] bytes)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return MD5.HashData(bytes).BytesToHex();
|
||||
#else
|
||||
using var md5 = MD5.Create();
|
||||
return md5.ComputeHash(bytes).BytesToHex();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion MD5
|
||||
|
||||
#region SHA-1
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-1 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-1 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha1(string str)
|
||||
{
|
||||
return Sha1(Encoding.UTF8.GetBytes(str));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a file using the SHA-1 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to read.</param>
|
||||
/// <returns>The SHA-1 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha1File(string fileName)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
using var fs = new FileStream(fileName, FileMode.Open);
|
||||
return sha1.ComputeHash(fs).BytesToHex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-1 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-1 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha1(byte[] bytes)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return SHA1.HashData(bytes).BytesToHex();
|
||||
#else
|
||||
using var sha1 = SHA1.Create();
|
||||
return sha1.ComputeHash(bytes).BytesToHex();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion SHA-1
|
||||
|
||||
#region SHA-256
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-256 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-256 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha256(string str)
|
||||
{
|
||||
return Sha256(Encoding.UTF8.GetBytes(str));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a file using the SHA-256 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to read.</param>
|
||||
/// <returns>The SHA-256 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha256File(string fileName)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var fs = new FileStream(fileName, FileMode.Open);
|
||||
return sha256.ComputeHash(fs).BytesToHex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-256 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-256 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha256(byte[] bytes)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return SHA256.HashData(bytes).BytesToHex();
|
||||
#else
|
||||
using var sha256 = SHA256.Create();
|
||||
return sha256.ComputeHash(bytes).BytesToHex();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion SHA-256
|
||||
|
||||
#region SHA-512
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a string using the SHA-512 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="str">The string to hash, using UTF-8 encoding.</param>
|
||||
/// <returns>The SHA-512 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha512(string str)
|
||||
{
|
||||
return Sha512(Encoding.UTF8.GetBytes(str));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash value from a file using the SHA-512 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to read.</param>
|
||||
/// <returns>The SHA-512 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha512File(string fileName)
|
||||
{
|
||||
using var sha512 = SHA512.Create();
|
||||
using var fs = new FileStream(fileName, FileMode.Open);
|
||||
return sha512.ComputeHash(fs).BytesToHex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a byte array value using the SHA-512 algorithm.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte array.</param>
|
||||
/// <returns>The SHA-512 hash value, in hexadecimal notation.</returns>
|
||||
public static string Sha512(byte[] bytes)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
return SHA512.HashData(bytes).BytesToHex();
|
||||
#else
|
||||
using var sha512 = SHA512.Create();
|
||||
return sha512.ComputeHash(bytes).BytesToHex();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion SHA-512
|
||||
|
||||
#endregion Hashing
|
||||
|
||||
#region Random
|
||||
|
||||
/// <summary>
|
||||
/// Generates an array with random (non-zero) bytes.
|
||||
/// </summary>
|
||||
/// <param name="count">The number of bytes to generate.</param>
|
||||
/// <returns></returns>
|
||||
public static byte[] GetRandomBytes(int count)
|
||||
{
|
||||
using var gen = RandomNumberGenerator.Create();
|
||||
byte[] bytes = new byte[count];
|
||||
gen.GetNonZeroBytes(bytes);
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a string with random characters.
|
||||
/// </summary>
|
||||
/// <param name="length">The length of the string to generate.</param>
|
||||
/// <param name="pool">The characters to use (Default: [a-zA-Z0-9]).</param>
|
||||
/// <returns></returns>
|
||||
public static string GetRandomString(int length, string pool = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pool))
|
||||
pool = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
|
||||
|
||||
var sb = new StringBuilder(length);
|
||||
int multiply = sizeof(int) / sizeof(byte);
|
||||
int len = length * multiply;
|
||||
byte[] bytes = GetRandomBytes(len);
|
||||
for (int i = 0; i < bytes.Length; i += multiply)
|
||||
{
|
||||
uint number = BitConverter.ToUInt32(bytes, i);
|
||||
sb.Append(pool[(int)(number % pool.Length)]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#endregion Random
|
||||
|
||||
#region Secure probing
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two byte arrays are equal in constant time. This method does not stop
|
||||
/// early if a difference was detected, unless the length differs.
|
||||
/// </summary>
|
||||
/// <param name="a">The first array.</param>
|
||||
/// <param name="b">The second array.</param>
|
||||
/// <returns>true, if both arrays are equal; otherwise, false.</returns>
|
||||
public static bool SecureEquals(byte[] a, byte[] b)
|
||||
{
|
||||
if ((a == null) != (b == null))
|
||||
return false;
|
||||
if (a.Length != b.Length)
|
||||
return false;
|
||||
|
||||
int differentBits = 0;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
differentBits |= a[i] ^ b[i];
|
||||
}
|
||||
return differentBits == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two strings are equal in constant time. This method does not stop
|
||||
/// early if a difference was detected, unless the length differs.
|
||||
/// </summary>
|
||||
/// <param name="a">The first string.</param>
|
||||
/// <param name="b">The second string.</param>
|
||||
/// <returns>true, if both strings are equal; otherwise, false.</returns>
|
||||
public static bool SecureEquals(string a, string b)
|
||||
{
|
||||
if ((a == null) != (b == null))
|
||||
return false;
|
||||
if (a.Length != b.Length)
|
||||
return false;
|
||||
|
||||
int differentBits = 0;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
differentBits |= a[i] ^ b[i];
|
||||
}
|
||||
return differentBits == 0;
|
||||
}
|
||||
|
||||
#endregion Secure probing
|
||||
|
||||
#endregion Static methods
|
||||
}
|
||||
}
|
||||
533
src/AMWD.Common/Utilities/DelayedTask.cs
Normal file
533
src/AMWD.Common/Utilities/DelayedTask.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AMWD.Common.Utilities
|
||||
{
|
||||
// originally inspired by a code from Yves Goergen (unclassified.software).
|
||||
|
||||
/// <summary>
|
||||
/// Implements an awaitable task that runs after a specified delay. The delay can be reset.
|
||||
/// By resetting the delay, the task can be executed multiple times.
|
||||
/// </summary>
|
||||
public class DelayedTask
|
||||
{
|
||||
#region Data
|
||||
|
||||
private Timer _timer;
|
||||
|
||||
private bool _nextRunPending;
|
||||
|
||||
/// <summary>
|
||||
/// The synchronisation object.
|
||||
/// </summary>
|
||||
protected readonly object syncLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// The exception handler.
|
||||
/// </summary>
|
||||
protected Action<Exception> exceptionHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the <see cref="Task"/> for the <see cref="GetAwaiter"/> method.
|
||||
/// </summary>
|
||||
protected TaskCompletionSourceWrapper tcs;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action to execute.
|
||||
/// </summary>
|
||||
protected Action Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the timer is running and an execution is scheduled. This
|
||||
/// is mutually exclusive to <see cref="IsRunning"/>.
|
||||
/// </summary>
|
||||
public bool IsWaitingToRun { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the action is currently running. This is mutually
|
||||
/// exclusive to <see cref="IsWaitingToRun"/>.
|
||||
/// </summary>
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delay to wait before executing the action.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get; protected set; }
|
||||
|
||||
#endregion Data
|
||||
|
||||
#region Static methods
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new task instance that executes the specified action after the delay, but does
|
||||
/// not start it yet.
|
||||
/// </summary>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
/// <param name="delay">The delay.</param>
|
||||
/// <returns></returns>
|
||||
public static DelayedTask Create(Action action, TimeSpan delay)
|
||||
=> new() { Action = action, Delay = delay };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new task instance that executes the specified action after the delay, but does
|
||||
/// not start it yet.
|
||||
/// </summary>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
/// <param name="delay">The delay.</param>
|
||||
/// <returns></returns>
|
||||
public static DelayedTaskWithResult<TResult> Create<TResult>(Func<TResult> action, TimeSpan delay)
|
||||
=> DelayedTaskWithResult<TResult>.Create(action, delay);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the specified action after the delay.
|
||||
/// </summary>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
/// <param name="delay">The delay.</param>
|
||||
/// <returns></returns>
|
||||
public static DelayedTask Run(Action action, TimeSpan delay)
|
||||
=> new DelayedTask { Action = action, Delay = delay }.Start();
|
||||
|
||||
/// <summary>
|
||||
/// Executes the specified action after the delay.
|
||||
/// </summary>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
/// <param name="delay">The delay.</param>
|
||||
/// <returns></returns>
|
||||
public static DelayedTaskWithResult<TResult> Run<TResult>(Func<TResult> action, TimeSpan delay)
|
||||
=> DelayedTaskWithResult<TResult>.Run(action, delay);
|
||||
|
||||
#endregion Static methods
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelayedTask"/> class.
|
||||
/// </summary>
|
||||
protected DelayedTask()
|
||||
{
|
||||
tcs = CreateTcs();
|
||||
SetLastResult(tcs);
|
||||
}
|
||||
|
||||
#endregion Constructors
|
||||
|
||||
#region Public instance methods
|
||||
|
||||
/// <summary>
|
||||
/// Resets the delay and restarts the timer. If an execution is currently pending, it is
|
||||
/// postponed until the full delay has elapsed again. If no execution is pending, the action
|
||||
/// will be executed again after the delay.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
if (!IsWaitingToRun && !IsRunning)
|
||||
{
|
||||
// Let callers wait for the next execution
|
||||
tcs = CreateTcs();
|
||||
}
|
||||
IsWaitingToRun = true;
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Change(Delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer = new Timer(OnTimerCallback, null, Delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the delay. Any pending execution is cleared. If the action was pending but not
|
||||
/// yet executing, this task is cancelled. If the action was not pending or is already
|
||||
/// executing, this task will be completed successfully after the action has completed.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
TaskCompletionSourceWrapper localTcs = null;
|
||||
lock (syncLock)
|
||||
{
|
||||
IsWaitingToRun = false;
|
||||
_nextRunPending = false;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
if (!IsRunning)
|
||||
{
|
||||
localTcs = tcs;
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the task (as cancelled) so that nobody needs to wait for an execution that
|
||||
// isn't currently scheduled
|
||||
localTcs?.TrySetCanceled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a pending execution immediately, not waiting for the timer to elapse.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c>, if an execution was started; otherwise, <c>false</c>.</returns>
|
||||
public bool ExecutePending()
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
if (!IsWaitingToRun && !IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
IsWaitingToRun = true;
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer = new Timer(OnTimerCallback, null, TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an awaiter used to await this <see cref="DelayedTask"/>.
|
||||
/// </summary>
|
||||
/// <returns>An awaiter instance.</returns>
|
||||
public TaskAwaiter GetAwaiter()
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
return tcs.Task.GetAwaiter();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a <see cref="System.Threading.Tasks.Task"/> that represents the current awaitable
|
||||
/// operation.
|
||||
/// </summary>
|
||||
public Task Task
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs an implicit conversion from <see cref="DelayedTask"/> to
|
||||
/// <see cref="System.Threading.Tasks.Task"/>.
|
||||
/// </summary>
|
||||
/// <param name="delayedTask">The <see cref="DelayedTask"/> instance to cast.</param>
|
||||
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the current
|
||||
/// awaitable operation.</returns>
|
||||
public static implicit operator Task(DelayedTask delayedTask) => delayedTask.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exception of the last execution. If the action has not yet thrown any
|
||||
/// exceptions, this will return null.
|
||||
/// </summary>
|
||||
public Exception Exception => Task.Exception;
|
||||
|
||||
/// <summary>
|
||||
/// Adds an unhandled exception handler to this <see cref="DelayedTask"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="exceptionHandler">The action that handles an exception.</param>
|
||||
/// <returns>The current instance.</returns>
|
||||
public DelayedTask WithExceptionHandler(Action<Exception> exceptionHandler)
|
||||
{
|
||||
this.exceptionHandler = exceptionHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
#endregion Public instance methods
|
||||
|
||||
#region Non-public methods
|
||||
|
||||
/// <summary>
|
||||
/// Starts the current instance after creating it.
|
||||
/// </summary>
|
||||
/// <returns>The current instance.</returns>
|
||||
protected DelayedTask Start()
|
||||
{
|
||||
tcs = CreateTcs();
|
||||
IsWaitingToRun = true;
|
||||
_timer = new Timer(OnTimerCallback, null, Delay, Timeout.InfiniteTimeSpan);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="TaskCompletionSourceWrapper"/> instance.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected virtual TaskCompletionSourceWrapper CreateTcs()
|
||||
=> new TaskCompletionSourceWrapper<object>();
|
||||
|
||||
/// <summary>
|
||||
/// Called when the timer has elapsed.
|
||||
/// </summary>
|
||||
/// <param name="state">Unused.</param>
|
||||
protected void OnTimerCallback(object state)
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
if (!IsWaitingToRun)
|
||||
{
|
||||
// Already cancelled, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
IsWaitingToRun = false;
|
||||
if (IsRunning)
|
||||
{
|
||||
// Currently running, remember and do nothing for now
|
||||
_nextRunPending = true;
|
||||
return;
|
||||
}
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
// Run as long as there are pending executions and the instance has not been disposed of
|
||||
bool runAgain;
|
||||
TaskCompletionSourceWrapper localTcs = null;
|
||||
Exception exception = null;
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exception = ex;
|
||||
lock (syncLock)
|
||||
{
|
||||
runAgain = false;
|
||||
IsRunning = false;
|
||||
_nextRunPending = false;
|
||||
localTcs = tcs;
|
||||
if (!IsWaitingToRun)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
exceptionHandler?.Invoke(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
runAgain = _nextRunPending;
|
||||
IsRunning = runAgain;
|
||||
_nextRunPending = false;
|
||||
if (!runAgain)
|
||||
{
|
||||
if (!IsWaitingToRun)
|
||||
{
|
||||
localTcs = tcs;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (runAgain);
|
||||
|
||||
// Unblock waiters if not already waiting for the next execution.
|
||||
// This task can be awaited again after the Reset method has been called.
|
||||
if (exception != null)
|
||||
{
|
||||
localTcs?.TrySetException(exception);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetLastResult(localTcs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the action of the task.
|
||||
/// </summary>
|
||||
protected virtual void Run() => Action();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the <see cref="TaskCompletionSourceWrapper"/> result from the last action.
|
||||
/// </summary>
|
||||
/// <param name="tcs">The <see cref="TaskCompletionSourceWrapper"/> to set the result of.</param>
|
||||
protected virtual void SetLastResult(TaskCompletionSourceWrapper tcs)
|
||||
{
|
||||
var myTcs = (TaskCompletionSourceWrapper<object>)tcs;
|
||||
myTcs?.TrySetResult(default);
|
||||
}
|
||||
|
||||
#endregion Non-public methods
|
||||
|
||||
#region Internal TaskCompletionSourceWrapper classes
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="TaskCompletionSource{TResult}"/> instance in a non-generic way to
|
||||
/// allow sharing it in the non-generic base class.
|
||||
/// </summary>
|
||||
protected abstract class TaskCompletionSourceWrapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Task{TResult}"/> of the <see cref="TaskCompletionSource{TResult}"/>.
|
||||
/// </summary>
|
||||
public abstract Task Task { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to transition the underlying <see cref="Task{TResult}"/> into the
|
||||
/// <see cref="TaskStatus.Faulted"/> state and binds it to a specified exception.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception to bind to this <see cref="Task{TResult}"/>.</param>
|
||||
/// <seealso cref="TaskCompletionSource{TResult}.TrySetException(Exception)"/>
|
||||
public abstract void TrySetException(Exception exception);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to transition the underlying <see cref="Task{TResult}"/> into the
|
||||
/// <see cref="TaskStatus.Canceled"/> state.
|
||||
/// </summary>
|
||||
/// <seealso cref="TaskCompletionSource{TResult}.TrySetCanceled()"/>
|
||||
public abstract void TrySetCanceled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TaskCompletionSourceWrapper"/> that provides a result value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the result value.</typeparam>
|
||||
protected class TaskCompletionSourceWrapper<TResult> : TaskCompletionSourceWrapper
|
||||
{
|
||||
private readonly TaskCompletionSource<TResult> _tcs;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Task{TResult}"/> of the <see cref="TaskCompletionSource{TResult}"/>.
|
||||
/// </summary>
|
||||
public override Task Task => _tcs.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TaskCompletionSourceWrapper{TResult}"/> class.
|
||||
/// </summary>
|
||||
public TaskCompletionSourceWrapper()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<TResult>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to transition the underlying <see cref="Task{TResult}"/> into the
|
||||
/// <see cref="TaskStatus.RanToCompletion"/> state.
|
||||
/// </summary>
|
||||
/// <param name="result">The result value to bind to this <see cref="Task{TResult}"/>.</param>
|
||||
/// <seealso cref="TaskCompletionSource{TResult}.TrySetResult(TResult)"/>
|
||||
public void TrySetResult(TResult result) => _tcs.TrySetResult(result);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void TrySetException(Exception exception) => _tcs.TrySetException(exception);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void TrySetCanceled() => _tcs.TrySetCanceled();
|
||||
}
|
||||
|
||||
#endregion Internal TaskCompletionSourceWrapper classes
|
||||
}
|
||||
|
||||
#region Generic derived classes
|
||||
|
||||
/// <summary>
|
||||
/// Implements an awaitable task that runs after a specified delay. The delay can be reset
|
||||
/// before and after the task has run.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the return value of the action.</typeparam>
|
||||
public class DelayedTaskWithResult<TResult> : DelayedTask
|
||||
{
|
||||
/// <summary>
|
||||
/// The result of the last execution of the action.
|
||||
/// </summary>
|
||||
protected TResult lastResult;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action to execute.
|
||||
/// </summary>
|
||||
protected new Func<TResult> Action { get; set; }
|
||||
|
||||
internal static DelayedTaskWithResult<TResult> Create(Func<TResult> action, TimeSpan delay)
|
||||
=> new() { Action = action, Delay = delay };
|
||||
|
||||
internal static DelayedTaskWithResult<TResult> Run(Func<TResult> action, TimeSpan delay)
|
||||
=> (DelayedTaskWithResult<TResult>)new DelayedTaskWithResult<TResult> { Action = action, Delay = delay }.Start();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override TaskCompletionSourceWrapper CreateTcs()
|
||||
=> new TaskCompletionSourceWrapper<TResult>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Run()
|
||||
{
|
||||
lastResult = Action();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void SetLastResult(TaskCompletionSourceWrapper tcs)
|
||||
{
|
||||
var myTcs = (TaskCompletionSourceWrapper<TResult>)tcs;
|
||||
myTcs?.TrySetResult(lastResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an awaiter used to await this <see cref="DelayedTask"/>.
|
||||
/// </summary>
|
||||
/// <returns>An awaiter instance.</returns>
|
||||
public new TaskAwaiter<TResult> GetAwaiter()
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
var myTcs = (TaskCompletionSourceWrapper<TResult>)tcs;
|
||||
var myTask = (Task<TResult>)myTcs.Task;
|
||||
return myTask.GetAwaiter();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a <see cref="Task{TResult}"/> that represents the current awaitable operation.
|
||||
/// </summary>
|
||||
public new Task<TResult> Task
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncLock)
|
||||
{
|
||||
var myTcs = (TaskCompletionSourceWrapper<TResult>)tcs;
|
||||
var myTask = (Task<TResult>)myTcs.Task;
|
||||
return myTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs an implicit conversion from <see cref="DelayedTaskWithResult{TResult}"/> to
|
||||
/// <see cref="Task{TResult}"/>.
|
||||
/// </summary>
|
||||
/// <param name="delayedTask">The <see cref="DelayedTaskWithResult{TResult}"/> instance to cast.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> that represents the current awaitable operation.</returns>
|
||||
public static implicit operator Task<TResult>(DelayedTaskWithResult<TResult> delayedTask)
|
||||
=> delayedTask.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Adds an unhandled exception handler to this <see cref="DelayedTask"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="exceptionHandler">The action that handles an exception.</param>
|
||||
/// <returns>The current instance.</returns>
|
||||
public new DelayedTaskWithResult<TResult> WithExceptionHandler(Action<Exception> exceptionHandler)
|
||||
{
|
||||
this.exceptionHandler = exceptionHandler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Generic derived classes
|
||||
}
|
||||
144
src/AMWD.Common/Utilities/NetworkHelper.cs
Normal file
144
src/AMWD.Common/Utilities/NetworkHelper.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
#if NET8_0_OR_GREATER
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
#else
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
|
||||
#endif
|
||||
|
||||
namespace AMWD.Common.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides some network utils.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public static class NetworkHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to resolve a <paramref name="hostname"/> to its <see cref="IPAddress"/>es.
|
||||
/// </summary>
|
||||
/// <param name="hostname">The hostname to resolve.</param>
|
||||
/// <param name="addressFamily">An address family to use (available: <see cref="AddressFamily.InterNetwork"/> and <see cref="AddressFamily.InterNetworkV6"/>).</param>
|
||||
/// <returns>The resolved <see cref="IPAddress"/>es or an empty list.</returns>
|
||||
public static List<IPAddress> ResolveHost(string hostname, AddressFamily addressFamily = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostname))
|
||||
return [];
|
||||
|
||||
if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6)
|
||||
addressFamily = AddressFamily.Unspecified;
|
||||
|
||||
var ipAddress = ResolveIpAddress(hostname, addressFamily);
|
||||
// the name was an ip address, should not happen but experience tells other stories
|
||||
if (ipAddress != null)
|
||||
return [ipAddress];
|
||||
|
||||
try
|
||||
{
|
||||
return Dns.GetHostAddresses(hostname)
|
||||
.FilterAddressFamily(addressFamily)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve an interface name to its v4 and v6 <see cref="IPAddress"/>es.
|
||||
/// </summary>
|
||||
/// <param name="interfaceName">The interface name to resolve.</param>
|
||||
/// <param name="addressFamily">An address family to use (available: <see cref="AddressFamily.InterNetwork"/> and <see cref="AddressFamily.InterNetworkV6"/>).</param>
|
||||
/// <returns>The resolved <see cref="IPAddress"/>es or an empty list.</returns>
|
||||
public static List<IPAddress> ResolveInterface(string interfaceName, AddressFamily addressFamily = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(interfaceName))
|
||||
return [];
|
||||
|
||||
if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6)
|
||||
addressFamily = AddressFamily.Unspecified;
|
||||
|
||||
var ipAddress = ResolveIpAddress(interfaceName, addressFamily);
|
||||
// the name was an ip address, should not happen but experience tells other stories
|
||||
if (ipAddress != null)
|
||||
return [ipAddress];
|
||||
|
||||
try
|
||||
{
|
||||
return NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(nic => nic.Name.Equals(interfaceName, StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses.Select(uai => uai.Address))
|
||||
.FilterAddressFamily(addressFamily)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the <see cref="IPNetwork"/> and expands the whole subnet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The network identifier and the broadcast address are included in the response list.
|
||||
/// </remarks>
|
||||
/// <param name="network">The <see cref="IPNetwork"/> to expand.</param>
|
||||
/// <returns>A list with all valid <see cref="IPAddress"/>es.</returns>
|
||||
public static List<IPAddress> ExpandNetwork(this IPNetwork network)
|
||||
{
|
||||
var list = new List<IPAddress>();
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
var ipAddress = network.BaseAddress;
|
||||
#else
|
||||
var ipAddress = network.Prefix;
|
||||
#endif
|
||||
while (network.Contains(ipAddress))
|
||||
{
|
||||
list.Add(ipAddress);
|
||||
ipAddress = ipAddress.Increment();
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IPAddress ResolveIpAddress(string address, AddressFamily addressFamily)
|
||||
{
|
||||
if (IPAddress.TryParse(address, out var ipAddress))
|
||||
{
|
||||
// the address is whether IPv4 nor IPv6
|
||||
if (ipAddress.AddressFamily != AddressFamily.InterNetwork && ipAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
return null;
|
||||
|
||||
// the address does not match the required address family
|
||||
if (addressFamily != AddressFamily.Unspecified && ipAddress.AddressFamily != addressFamily)
|
||||
return null;
|
||||
|
||||
return ipAddress.IsIPv4MappedToIPv6 ? ipAddress.MapToIPv4() : ipAddress;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<IPAddress> FilterAddressFamily(this IEnumerable<IPAddress> source, AddressFamily addressFamily)
|
||||
{
|
||||
return source
|
||||
.Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.Where(a => addressFamily == AddressFamily.Unspecified || a.AddressFamily == addressFamily)
|
||||
.OrderBy(a => a.AddressFamily);
|
||||
}
|
||||
|
||||
internal static void SwapBigEndian(byte[] array)
|
||||
{
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(array);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Directory.Build.props
Normal file
48
src/Directory.Build.props
Normal file
@@ -0,0 +1,48 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<NrtRevisionFormat>{semvertag:main}{!:-dev}</NrtRevisionFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<EmbedUntrackedSources>false</EmbedUntrackedSources>
|
||||
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<RepositoryUrl>https://git.am-wd.de/am-wd/common.git</RepositoryUrl>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<PackageProjectUrl>https://wiki.am-wd.de/libs/common</PackageProjectUrl>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
|
||||
<Company>AM.WD</Company>
|
||||
<Authors>Andreas Müller</Authors>
|
||||
<Copyright>© {copyright:2020-} AM.WD</Copyright>
|
||||
<Description>Library with classes and extensions used frequently on AM.WD projects.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../icon.png" Pack="true" PackagePath="/" />
|
||||
<None Include="../../README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(CI)' == 'true'">
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(CI)' == 'true'">
|
||||
<SourceLinkGitLabHost Include="$(CI_SERVER_HOST)" Version="$(CI_SERVER_VERSION)" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AMWD.NetRevisionTask" Version="1.2.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
</Project>
|
||||
Reference in New Issue
Block a user