feat: session A — config binding via appsettings.json (67 stubs complete)
Add JSON attributes to ServerOptions, four custom JSON converters (NatsDurationJsonConverter, TlsVersionJsonConverter, NatsUrlJsonConverter, StorageSizeJsonConverter), ServerOptionsConfiguration for JSON file/string binding, and 15 tests covering config parsing, duration parsing, and size parsing. Mark 67 opts.go features complete in porting.db.
This commit is contained in:
169
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs
Normal file
169
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from parse utility functions in server/opts.go in the NATS server Go source.
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
|
||||
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
private static readonly Regex Pattern = new(
|
||||
@"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(FormatDuration(value));
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
|
||||
/// </summary>
|
||||
public static TimeSpan Parse(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
throw new FormatException("Duration string is empty");
|
||||
|
||||
// Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
|
||||
var m = Pattern.Match(s);
|
||||
if (m.Success && m.Value.Length > 0)
|
||||
{
|
||||
var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
|
||||
var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
|
||||
var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
|
||||
var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
|
||||
var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
|
||||
var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
|
||||
return new TimeSpan(0, hours, minutes, seconds, ms)
|
||||
+ TimeSpan.FromMicroseconds(us)
|
||||
+ TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
|
||||
}
|
||||
|
||||
// Try .NET TimeSpan.Parse (handles "hh:mm:ss")
|
||||
if (TimeSpan.TryParse(s, out var ts)) return ts;
|
||||
|
||||
throw new FormatException($"Cannot parse duration string: \"{s}\"");
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns";
|
||||
if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
|
||||
if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
|
||||
if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s";
|
||||
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
|
||||
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
|
||||
{
|
||||
public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString()?.Trim() ?? string.Empty;
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString());
|
||||
|
||||
public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
|
||||
{
|
||||
"1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
|
||||
"1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
|
||||
_ => throw new FormatException($"Unknown TLS version: \"{s}\""),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and normalises a NATS URL string (nats://host:port).
|
||||
/// Mirrors Go <c>parseURL</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class NatsUrlJsonConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString() ?? string.Empty;
|
||||
return Normalise(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value);
|
||||
|
||||
public static string Normalise(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return url;
|
||||
url = url.Trim();
|
||||
if (!url.Contains("://")) url = "nats://" + url;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new FormatException($"Invalid NATS URL: \"{url}\"");
|
||||
return uri.ToString().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
|
||||
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class StorageSizeJsonConverter : JsonConverter<long>
|
||||
{
|
||||
private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
return reader.GetInt64();
|
||||
}
|
||||
var raw = reader.GetString() ?? "0";
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
|
||||
=> writer.WriteNumberValue(value);
|
||||
|
||||
public static long Parse(string s)
|
||||
{
|
||||
if (long.TryParse(s, out var n)) return n;
|
||||
var m = Pattern.Match(s.Trim());
|
||||
if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
|
||||
var num = double.Parse(m.Groups[1].Value);
|
||||
var suffix = m.Groups[2].Value.ToUpperInvariant();
|
||||
return suffix switch
|
||||
{
|
||||
"K" or "KB" => (long)(num * 1024),
|
||||
"M" or "MB" => (long)(num * 1024 * 1024),
|
||||
"G" or "GB" => (long)(num * 1024 * 1024 * 1024),
|
||||
"T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
|
||||
_ => (long)num,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/opts.go in the NATS server Go source.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
|
||||
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
|
||||
/// and all <c>parse*</c> helper functions in server/opts.go.
|
||||
/// </summary>
|
||||
public static class ServerOptionsConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
|
||||
/// NATS-specific JSON converters.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
opts.Converters.Add(new NatsDurationJsonConverter());
|
||||
opts.Converters.Add(new TlsVersionJsonConverter());
|
||||
opts.Converters.Add(new StorageSizeJsonConverter());
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON file at <paramref name="path"/> and returns a bound
|
||||
/// <see cref="ServerOptions"/> instance.
|
||||
/// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
|
||||
/// </summary>
|
||||
public static ServerOptions ProcessConfigFile(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Configuration file not found: {path}", path);
|
||||
|
||||
var json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return ProcessConfigString(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
|
||||
/// Mirrors Go <c>Options.ProcessConfigString</c>.
|
||||
/// </summary>
|
||||
public static ServerOptions ProcessConfigString(string json)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNullOrEmpty(json);
|
||||
var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
|
||||
?? new ServerOptions();
|
||||
PostProcess(opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
|
||||
/// to a <see cref="ServerOptions"/> instance.
|
||||
/// The configuration section should be the root or a named section such as "NatsServer".
|
||||
/// </summary>
|
||||
public static void BindConfiguration(IConfiguration config, ServerOptions target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
config.Bind(target);
|
||||
PostProcess(target);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Applies defaults and cross-field validation after loading.
|
||||
/// Mirrors the end of <c>Options.processConfigFile</c> and
|
||||
/// <c>configureSystemAccount</c> in server/opts.go.
|
||||
/// </summary>
|
||||
private static void PostProcess(ServerOptions opts)
|
||||
{
|
||||
// Apply default port if not set.
|
||||
if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;
|
||||
|
||||
// Apply default host if not set.
|
||||
if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;
|
||||
|
||||
// Apply default max payload.
|
||||
if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;
|
||||
|
||||
// Apply default auth timeout.
|
||||
if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout;
|
||||
|
||||
// Ensure SystemAccount defaults if not set.
|
||||
ConfigureSystemAccount(opts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the system account name from options.
|
||||
/// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
|
||||
/// </summary>
|
||||
private static void ConfigureSystemAccount(ServerOptions opts)
|
||||
{
|
||||
// If system account already set, nothing to do.
|
||||
if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
|
||||
// Default to "$SYS" if not explicitly disabled.
|
||||
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,12 @@ public static class ServerConstants
|
||||
// Auth timeout — mirrors AUTH_TIMEOUT.
|
||||
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout.
|
||||
public const double DefaultAuthTimeout = 2.0;
|
||||
|
||||
// Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE.
|
||||
public const int MaxPayload = MaxPayloadSize;
|
||||
|
||||
// How often pings are sent — mirrors DEFAULT_PING_INTERVAL.
|
||||
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
@@ -31,20 +32,28 @@ public sealed partial class ServerOptions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public string ConfigFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("server_name")]
|
||||
public string ServerName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
public bool DontListen { get; set; }
|
||||
[JsonPropertyName("client_advertise")]
|
||||
public string ClientAdvertise { get; set; } = string.Empty;
|
||||
public bool CheckConfig { get; set; }
|
||||
[JsonPropertyName("pid_file")]
|
||||
public string PidFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("ports_file_dir")]
|
||||
public string PortsFileDir { get; set; } = string.Empty;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logging & Debugging
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public bool Trace { get; set; }
|
||||
[JsonPropertyName("debug")]
|
||||
public bool Debug { get; set; }
|
||||
public bool TraceVerbose { get; set; }
|
||||
public bool TraceHeaders { get; set; }
|
||||
@@ -52,7 +61,9 @@ public sealed partial class ServerOptions
|
||||
public bool NoSigs { get; set; }
|
||||
public bool Logtime { get; set; }
|
||||
public bool LogtimeUtc { get; set; }
|
||||
[JsonPropertyName("logfile")]
|
||||
public string LogFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("log_size_limit")]
|
||||
public long LogSizeLimit { get; set; }
|
||||
public long LogMaxFiles { get; set; }
|
||||
public bool Syslog { get; set; }
|
||||
@@ -65,11 +76,14 @@ public sealed partial class ServerOptions
|
||||
// Networking & Limits
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("max_connections")]
|
||||
public int MaxConn { get; set; }
|
||||
public int MaxSubs { get; set; }
|
||||
public byte MaxSubTokens { get; set; }
|
||||
public int MaxControlLine { get; set; }
|
||||
[JsonPropertyName("max_payload")]
|
||||
public int MaxPayload { get; set; }
|
||||
[JsonPropertyName("max_pending")]
|
||||
public long MaxPending { get; set; }
|
||||
public bool NoFastProducerStall { get; set; }
|
||||
public bool ProxyRequired { get; set; }
|
||||
@@ -80,11 +94,16 @@ public sealed partial class ServerOptions
|
||||
// Connectivity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("ping_interval")]
|
||||
public TimeSpan PingInterval { get; set; }
|
||||
[JsonPropertyName("ping_max")]
|
||||
public int MaxPingsOut { get; set; }
|
||||
[JsonPropertyName("write_deadline")]
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
public WriteTimeoutPolicy WriteTimeout { get; set; }
|
||||
[JsonPropertyName("lame_duck_duration")]
|
||||
public TimeSpan LameDuckDuration { get; set; }
|
||||
[JsonPropertyName("lame_duck_grace_period")]
|
||||
public TimeSpan LameDuckGracePeriod { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -92,23 +111,33 @@ public sealed partial class ServerOptions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public string HttpHost { get; set; } = string.Empty;
|
||||
[JsonPropertyName("http_port")]
|
||||
public int HttpPort { get; set; }
|
||||
[JsonPropertyName("http_base_path")]
|
||||
public string HttpBasePath { get; set; } = string.Empty;
|
||||
[JsonPropertyName("https_port")]
|
||||
public int HttpsPort { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authentication & Authorization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
[JsonPropertyName("password")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
[JsonPropertyName("authorization")]
|
||||
public string Authorization { get; set; } = string.Empty;
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
[JsonPropertyName("no_auth_user")]
|
||||
public string NoAuthUser { get; set; } = string.Empty;
|
||||
public string DefaultSentinel { get; set; } = string.Empty;
|
||||
[JsonPropertyName("system_account")]
|
||||
public string SystemAccount { get; set; } = string.Empty;
|
||||
public bool NoSystemAccount { get; set; }
|
||||
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
|
||||
[JsonPropertyName("accounts")]
|
||||
public List<Account> Accounts { get; set; } = [];
|
||||
public AuthCalloutOpts? AuthCallout { get; set; }
|
||||
public bool AlwaysEnableNonce { get; set; }
|
||||
@@ -148,8 +177,11 @@ public sealed partial class ServerOptions
|
||||
// Cluster / Gateway / Leaf / WebSocket / MQTT
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public ClusterOpts Cluster { get; set; } = new();
|
||||
[JsonPropertyName("gateway")]
|
||||
public GatewayOpts Gateway { get; set; } = new();
|
||||
[JsonPropertyName("leafnodes")]
|
||||
public LeafNodeOpts LeafNode { get; set; } = new();
|
||||
public WebsocketOpts Websocket { get; set; } = new();
|
||||
public MqttOpts Mqtt { get; set; } = new();
|
||||
@@ -165,6 +197,7 @@ public sealed partial class ServerOptions
|
||||
// JetStream
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("jetstream")]
|
||||
public bool JetStream { get; set; }
|
||||
public bool NoJetStreamStrict { get; set; }
|
||||
public long JetStreamMaxMemory { get; set; }
|
||||
@@ -184,6 +217,7 @@ public sealed partial class ServerOptions
|
||||
public bool JetStreamMetaCompactSync { get; set; }
|
||||
public int StreamMaxBufferedMsgs { get; set; }
|
||||
public long StreamMaxBufferedSize { get; set; }
|
||||
[JsonPropertyName("store_dir")]
|
||||
public string StoreDir { get; set; } = string.Empty;
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="*" />
|
||||
|
||||
Reference in New Issue
Block a user