feat: add NATS config file parser (port of Go conf/parse.go)
Implements NatsConfParser with Parse, ParseFile, and ParseFileWithDigest methods. Supports nested maps/arrays, variable resolution with block scoping and environment fallback, bcrypt password literals, integer suffix multipliers, include directives, and cycle detection.
This commit is contained in:
404
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
404
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
// Port of Go conf/parse.go — recursive-descent parser for NATS config files.
|
||||
// Reference: golang/nats-server/conf/parse.go
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NATS configuration data (tokenized by <see cref="NatsConfLexer"/>) into
|
||||
/// a <c>Dictionary<string, object?></c> tree. Supports nested maps, arrays,
|
||||
/// variable references (block-scoped + environment), include directives, bcrypt
|
||||
/// password literals, and integer suffix multipliers.
|
||||
/// </summary>
|
||||
public static class NatsConfParser
|
||||
{
|
||||
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
|
||||
// and emits a Variable token whose value begins with "2a$" or "2b$".
|
||||
private const string BcryptPrefix2A = "2a$";
|
||||
private const string BcryptPrefix2B = "2b$";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration string into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Parse(string data)
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(filePath);
|
||||
var hashBytes = SHA256.HashData(rawBytes);
|
||||
var digest = "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
|
||||
var data = Encoding.UTF8.GetString(rawBytes);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir);
|
||||
state.Run();
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: parse an environment variable value by wrapping it in a synthetic
|
||||
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> ParseEnvValue(string value, HashSet<string> envVarReferences)
|
||||
{
|
||||
var synthetic = $"pk={value}";
|
||||
var tokens = NatsConfLexer.Tokenize(synthetic);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the mutable parsing state: context stack, key stack, token cursor.
|
||||
/// Mirrors the Go <c>parser</c> struct from conf/parse.go.
|
||||
/// </summary>
|
||||
private sealed class ParserState
|
||||
{
|
||||
private readonly IReadOnlyList<Token> _tokens;
|
||||
private readonly string _baseDir;
|
||||
private readonly HashSet<string> _envVarReferences;
|
||||
private int _pos;
|
||||
|
||||
// The context stack holds either Dictionary<string, object?> (map) or List<object?> (array).
|
||||
private readonly List<object> _ctxs = new(4);
|
||||
private object _ctx = null!;
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||
: this(tokens, baseDir, [])
|
||||
{
|
||||
}
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences)
|
||||
{
|
||||
_tokens = tokens;
|
||||
_baseDir = baseDir;
|
||||
_envVarReferences = envVarReferences;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
PushContext(Mapping);
|
||||
|
||||
Token prevToken = default;
|
||||
while (true)
|
||||
{
|
||||
var token = Next();
|
||||
if (token.Type == TokenType.Eof)
|
||||
{
|
||||
// Allow a trailing '}' (JSON-like configs) — mirror Go behavior.
|
||||
if (prevToken.Type == TokenType.Key && prevToken.Value != "}")
|
||||
{
|
||||
throw new FormatException($"Config is invalid at line {token.Line}:{token.Position}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
prevToken = token;
|
||||
ProcessItem(token);
|
||||
}
|
||||
}
|
||||
|
||||
private Token Next()
|
||||
{
|
||||
if (_pos >= _tokens.Count)
|
||||
{
|
||||
return new Token(TokenType.Eof, string.Empty, 0, 0);
|
||||
}
|
||||
|
||||
return _tokens[_pos++];
|
||||
}
|
||||
|
||||
private void PushContext(object ctx)
|
||||
{
|
||||
_ctxs.Add(ctx);
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
private object PopContext()
|
||||
{
|
||||
if (_ctxs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, context stack empty");
|
||||
}
|
||||
|
||||
var last = _ctxs[^1];
|
||||
_ctxs.RemoveAt(_ctxs.Count - 1);
|
||||
_ctx = _ctxs[^1];
|
||||
return last;
|
||||
}
|
||||
|
||||
private void PushKey(string key) => _keys.Add(key);
|
||||
|
||||
private string PopKey()
|
||||
{
|
||||
if (_keys.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, keys stack empty");
|
||||
}
|
||||
|
||||
var last = _keys[^1];
|
||||
_keys.RemoveAt(_keys.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void SetValue(object? val)
|
||||
{
|
||||
// Array context: append the value.
|
||||
if (_ctx is List<object?> array)
|
||||
{
|
||||
array.Add(val);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map context: pop the pending key and assign.
|
||||
if (_ctx is Dictionary<string, object?> map)
|
||||
{
|
||||
var key = PopKey();
|
||||
map[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessItem(Token token)
|
||||
{
|
||||
switch (token.Type)
|
||||
{
|
||||
case TokenType.Error:
|
||||
throw new FormatException($"Parse error on line {token.Line}: '{token.Value}'");
|
||||
|
||||
case TokenType.Key:
|
||||
PushKey(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.String:
|
||||
SetValue(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Bool:
|
||||
SetValue(ParseBool(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Integer:
|
||||
SetValue(ParseInteger(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Float:
|
||||
SetValue(ParseFloat(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.DateTime:
|
||||
SetValue(DateTimeOffset.Parse(token.Value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
|
||||
case TokenType.ArrayStart:
|
||||
PushContext(new List<object?>());
|
||||
break;
|
||||
|
||||
case TokenType.ArrayEnd:
|
||||
{
|
||||
var array = _ctx;
|
||||
PopContext();
|
||||
SetValue(array);
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.MapStart:
|
||||
PushContext(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
break;
|
||||
|
||||
case TokenType.MapEnd:
|
||||
SetValue(PopContext());
|
||||
break;
|
||||
|
||||
case TokenType.Variable:
|
||||
ResolveVariable(token);
|
||||
break;
|
||||
|
||||
case TokenType.Include:
|
||||
ProcessInclude(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Comment:
|
||||
// Skip comments entirely.
|
||||
break;
|
||||
|
||||
case TokenType.Eof:
|
||||
// Handled in the Run loop; should not reach here.
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new FormatException($"Unexpected token type {token.Type} on line {token.Line}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ParseBool(string value) =>
|
||||
value.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "yes" or "on" => true,
|
||||
"false" or "no" or "off" => false,
|
||||
_ => throw new FormatException($"Expected boolean value, but got '{value}'"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses an integer token value, handling optional size suffixes
|
||||
/// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does.
|
||||
/// </summary>
|
||||
private static long ParseInteger(string value)
|
||||
{
|
||||
// Find where digits end and potential suffix begins.
|
||||
var lastDigit = 0;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsDigit(c) && c != '-')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lastDigit++;
|
||||
}
|
||||
|
||||
var numStr = value[..lastDigit];
|
||||
if (!long.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
|
||||
{
|
||||
throw new FormatException($"Expected integer, but got '{value}'");
|
||||
}
|
||||
|
||||
var suffix = value[lastDigit..].Trim().ToLowerInvariant();
|
||||
return suffix switch
|
||||
{
|
||||
"" => num,
|
||||
"k" => num * 1000,
|
||||
"kb" or "ki" or "kib" => num * 1024,
|
||||
"m" => num * 1_000_000,
|
||||
"mb" or "mi" or "mib" => num * 1024 * 1024,
|
||||
"g" => num * 1_000_000_000,
|
||||
"gb" or "gi" or "gib" => num * 1024 * 1024 * 1024,
|
||||
"t" => num * 1_000_000_000_000,
|
||||
"tb" or "ti" or "tib" => num * 1024L * 1024 * 1024 * 1024,
|
||||
"p" => num * 1_000_000_000_000_000,
|
||||
"pb" or "pi" or "pib" => num * 1024L * 1024 * 1024 * 1024 * 1024,
|
||||
"e" => num * 1_000_000_000_000_000_000,
|
||||
"eb" or "ei" or "eib" => num * 1024L * 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
_ => throw new FormatException($"Unknown integer suffix '{suffix}' in '{value}'"),
|
||||
};
|
||||
}
|
||||
|
||||
private static double ParseFloat(string value)
|
||||
{
|
||||
if (!double.TryParse(value, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new FormatException($"Expected float, but got '{value}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a variable reference using block scoping: walks the context stack
|
||||
/// top-down looking in map contexts, then falls back to environment variables.
|
||||
/// Detects bcrypt password literals and reference cycles.
|
||||
/// </summary>
|
||||
private void ResolveVariable(Token token)
|
||||
{
|
||||
var varName = token.Value;
|
||||
|
||||
// Special case: raw bcrypt strings ($2a$... or $2b$...).
|
||||
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
|
||||
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
|
||||
{
|
||||
SetValue("$" + varName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk context stack from top (innermost scope) to bottom (outermost).
|
||||
for (var i = _ctxs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_ctxs[i] is Dictionary<string, object?> map && map.TryGetValue(varName, out var found))
|
||||
{
|
||||
SetValue(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found in any context map. Check environment variables.
|
||||
// First, detect cycles.
|
||||
if (!_envVarReferences.Add(varName))
|
||||
{
|
||||
throw new FormatException($"Variable reference cycle for '{varName}'");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envValue = Environment.GetEnvironmentVariable(varName);
|
||||
if (envValue is not null)
|
||||
{
|
||||
// Parse the env value through the full parser to get correct typing
|
||||
// (e.g., "42" becomes long 42, "true" becomes bool, etc.).
|
||||
var subResult = ParseEnvValue(envValue, _envVarReferences);
|
||||
if (subResult.TryGetValue("pk", out var parsedValue))
|
||||
{
|
||||
SetValue(parsedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_envVarReferences.Remove(varName);
|
||||
}
|
||||
|
||||
// Not found anywhere.
|
||||
throw new FormatException(
|
||||
$"Variable reference for '{varName}' on line {token.Line} can not be found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an include directive by parsing the referenced file and merging
|
||||
/// all its top-level keys into the current context.
|
||||
/// </summary>
|
||||
private void ProcessInclude(string includePath)
|
||||
{
|
||||
var fullPath = Path.Combine(_baseDir, includePath);
|
||||
var includeResult = ParseFile(fullPath);
|
||||
|
||||
foreach (var (key, value) in includeResult)
|
||||
{
|
||||
PushKey(key);
|
||||
SetValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal file
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class NatsConfParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_SimpleTopLevel_ReturnsCorrectTypes()
|
||||
{
|
||||
var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22");
|
||||
result["foo"].ShouldBe("1");
|
||||
result["bar"].ShouldBe(2.2);
|
||||
result["baz"].ShouldBe(true);
|
||||
result["boo"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Booleans_AllVariants()
|
||||
{
|
||||
foreach (var (input, expected) in new[] {
|
||||
("true", true), ("TRUE", true), ("yes", true), ("on", true),
|
||||
("false", false), ("FALSE", false), ("no", false), ("off", false)
|
||||
})
|
||||
{
|
||||
var result = NatsConfParser.Parse($"flag = {input}");
|
||||
result["flag"].ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IntegerWithSuffix_AppliesMultiplier()
|
||||
{
|
||||
var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb");
|
||||
result["a"].ShouldBe(1000L);
|
||||
result["b"].ShouldBe(2L * 1024 * 1024);
|
||||
result["c"].ShouldBe(3L * 1024 * 1024 * 1024);
|
||||
result["d"].ShouldBe(4L * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NestedMap_ReturnsDictionary()
|
||||
{
|
||||
var result = NatsConfParser.Parse("auth { user: admin, pass: secret }");
|
||||
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
auth["user"].ShouldBe("admin");
|
||||
auth["pass"].ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Array_ReturnsList()
|
||||
{
|
||||
var result = NatsConfParser.Parse("items = [1, 2, 3]");
|
||||
var items = result["items"].ShouldBeOfType<List<object?>>();
|
||||
items.Count.ShouldBe(3);
|
||||
items[0].ShouldBe(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Variable_ResolvesFromContext()
|
||||
{
|
||||
var result = NatsConfParser.Parse("index = 22\nfoo = $index");
|
||||
result["foo"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NestedVariable_UsesBlockScope()
|
||||
{
|
||||
var input = "index = 22\nnest {\n index = 11\n foo = $index\n}\nbar = $index";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var nest = result["nest"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
nest["foo"].ShouldBe(11L);
|
||||
result["bar"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EnvironmentVariable_ResolvesFromEnv()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello");
|
||||
try
|
||||
{
|
||||
var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345");
|
||||
result["val"].ShouldBe("hello");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UndefinedVariable_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() =>
|
||||
NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IncludeDirective_MergesFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\"");
|
||||
File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\"");
|
||||
var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf"));
|
||||
result["port"].ShouldBe(4222L);
|
||||
result["host"].ShouldBe("localhost");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleKeySeparators_AllWork()
|
||||
{
|
||||
var r1 = NatsConfParser.Parse("a = 1");
|
||||
var r2 = NatsConfParser.Parse("a : 1");
|
||||
var r3 = NatsConfParser.Parse("a 1");
|
||||
r1["a"].ShouldBe(1L);
|
||||
r2["a"].ShouldBe(1L);
|
||||
r3["a"].ShouldBe(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ErrorOnInvalidInput_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => NatsConfParser.Parse("= invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CommentsInsideBlocks_AreIgnored()
|
||||
{
|
||||
var input = "auth {\n # comment\n user: admin\n // another comment\n pass: secret\n}";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
auth["user"].ShouldBe("admin");
|
||||
auth["pass"].ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayOfMaps_Works()
|
||||
{
|
||||
var input = "users = [\n { user: alice, pass: pw1 }\n { user: bob, pass: pw2 }\n]";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var users = result["users"].ShouldBeOfType<List<object?>>();
|
||||
users.Count.ShouldBe(2);
|
||||
var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
first["user"].ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BcryptPassword_HandledAsString()
|
||||
{
|
||||
var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
((string)result["pass"]!).ShouldStartWith("$2a$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_WithDigest_ReturnsStableHash()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(conf, "port = 4222\nhost = \"localhost\"");
|
||||
var (result, digest) = NatsConfParser.ParseFileWithDigest(conf);
|
||||
result["port"].ShouldBe(4222L);
|
||||
digest.ShouldStartWith("sha256:");
|
||||
digest.Length.ShouldBeGreaterThan(10);
|
||||
|
||||
var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf);
|
||||
digest2.ShouldBe(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user