Update 0.1.2 - AutoDetect Debug before release
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MareSynchronos.WebAPI.AutoDetect;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
|
||||
namespace MareSynchronos.Services.AutoDetect;
|
||||
|
||||
public class AutoDetectRequestService
|
||||
{
|
||||
private readonly ILogger<AutoDetectRequestService> _logger;
|
||||
private readonly DiscoveryConfigProvider _configProvider;
|
||||
private readonly DiscoveryApiClient _client;
|
||||
private readonly MareConfigService _configService;
|
||||
|
||||
public AutoDetectRequestService(ILogger<AutoDetectRequestService> logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configProvider = configProvider;
|
||||
_client = client;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public async Task<bool> SendRequestAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
if (!_configService.Current.AllowAutoDetectPairRequests)
|
||||
{
|
||||
_logger.LogDebug("Nearby request blocked: AllowAutoDetectPairRequests is disabled");
|
||||
return false;
|
||||
}
|
||||
var endpoint = _configProvider.RequestEndpoint;
|
||||
if (string.IsNullOrEmpty(endpoint))
|
||||
{
|
||||
_logger.LogDebug("No request endpoint configured");
|
||||
return false;
|
||||
}
|
||||
return await _client.SendRequestAsync(endpoint!, token, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
170
MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs
Normal file
170
MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.WebAPI.SignalR;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
|
||||
namespace MareSynchronos.Services.AutoDetect;
|
||||
|
||||
public class DiscoveryConfigProvider
|
||||
{
|
||||
private readonly ILogger<DiscoveryConfigProvider> _logger;
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
|
||||
private WellKnownRoot? _config;
|
||||
private DateTimeOffset _lastLoad = DateTimeOffset.MinValue;
|
||||
|
||||
public DiscoveryConfigProvider(ILogger<DiscoveryConfigProvider> logger, ServerConfigurationManager serverManager, TokenProvider tokenProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverManager = serverManager;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public bool HasConfig => _config != null;
|
||||
public bool NearbyEnabled => _config?.NearbyDiscovery?.Enabled ?? false;
|
||||
public byte[]? Salt => _config?.NearbyDiscovery?.SaltBytes;
|
||||
public DateTimeOffset? SaltExpiresAt => _config?.NearbyDiscovery?.SaltExpiresAt;
|
||||
public int RefreshSec => _config?.NearbyDiscovery?.RefreshSec ?? 300;
|
||||
public int MinQueryIntervalMs => _config?.NearbyDiscovery?.Policies?.MinQueryIntervalMs ?? 2000;
|
||||
public int MaxQueryBatch => _config?.NearbyDiscovery?.Policies?.MaxQueryBatch ?? 100;
|
||||
public string? PublishEndpoint => _config?.NearbyDiscovery?.Endpoints?.Publish;
|
||||
public string? QueryEndpoint => _config?.NearbyDiscovery?.Endpoints?.Query;
|
||||
public string? RequestEndpoint => _config?.NearbyDiscovery?.Endpoints?.Request;
|
||||
|
||||
public bool TryLoadFromStapled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = _tokenProvider.GetStapledWellKnown(_serverManager.CurrentApiUrl);
|
||||
if (string.IsNullOrEmpty(json)) return false;
|
||||
|
||||
var root = JsonSerializer.Deserialize<WellKnownRoot>(json!);
|
||||
if (root == null) return false;
|
||||
|
||||
root.NearbyDiscovery?.Hydrate();
|
||||
_config = root;
|
||||
_lastLoad = DateTimeOffset.UtcNow;
|
||||
_logger.LogDebug("Loaded Nearby well-known (stapled), enabled={enabled}", NearbyEnabled);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse stapled well-known");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryFetchFromServerAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = _serverManager.CurrentApiUrl
|
||||
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase);
|
||||
// Try likely candidates based on nginx config
|
||||
string[] candidates =
|
||||
[
|
||||
"/.well-known/Umbra/client", // matches provided nginx
|
||||
"/.well-known/umbra", // lowercase variant
|
||||
];
|
||||
|
||||
using var http = new HttpClient();
|
||||
try
|
||||
{
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version!;
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MareSynchronos", $"{ver.Major}.{ver.Minor}.{ver.Build}"));
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (var path in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(new Uri(baseUrl), path);
|
||||
var json = await http.GetStringAsync(uri, ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(json)) continue;
|
||||
|
||||
var root = JsonSerializer.Deserialize<WellKnownRoot>(json);
|
||||
if (root == null) continue;
|
||||
|
||||
root.NearbyDiscovery?.Hydrate();
|
||||
_config = root;
|
||||
_lastLoad = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation("Loaded Nearby well-known (http {path}), enabled={enabled}", path, NearbyEnabled);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Nearby well-known fetch failed for {path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Nearby well-known not found via HTTP candidates");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Nearby well-known via HTTP");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
if (_config?.NearbyDiscovery?.SaltExpiresAt == null) return false;
|
||||
return DateTimeOffset.UtcNow > _config.NearbyDiscovery.SaltExpiresAt;
|
||||
}
|
||||
|
||||
// DTOs for well-known JSON
|
||||
private sealed class WellKnownRoot
|
||||
{
|
||||
[JsonPropertyName("features")] public Features? Features { get; set; }
|
||||
[JsonPropertyName("nearby_discovery")] public Nearby? NearbyDiscovery { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Features
|
||||
{
|
||||
[JsonPropertyName("nearby_discovery")] public bool NearbyDiscovery { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Nearby
|
||||
{
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
[JsonPropertyName("hash_algo")] public string? HashAlgo { get; set; }
|
||||
[JsonPropertyName("salt_b64")] public string? SaltB64 { get; set; }
|
||||
[JsonPropertyName("salt_expires_at")] public string? SaltExpiresAtRaw { get; set; }
|
||||
[JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; } = 300;
|
||||
[JsonPropertyName("endpoints")] public Endpoints? Endpoints { get; set; }
|
||||
[JsonPropertyName("policies")] public Policies? Policies { get; set; }
|
||||
|
||||
[JsonIgnore] public byte[]? SaltBytes { get; private set; }
|
||||
[JsonIgnore] public DateTimeOffset? SaltExpiresAt { get; private set; }
|
||||
|
||||
public void Hydrate()
|
||||
{
|
||||
try { SaltBytes = string.IsNullOrEmpty(SaltB64) ? null : Convert.FromBase64String(SaltB64!); } catch { SaltBytes = null; }
|
||||
if (DateTimeOffset.TryParse(SaltExpiresAtRaw, out var dto)) SaltExpiresAt = dto;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Endpoints
|
||||
{
|
||||
[JsonPropertyName("publish")] public string? Publish { get; set; }
|
||||
[JsonPropertyName("query")] public string? Query { get; set; }
|
||||
[JsonPropertyName("request")] public string? Request { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Policies
|
||||
{
|
||||
[JsonPropertyName("max_query_batch")] public int MaxQueryBatch { get; set; } = 100;
|
||||
[JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; } = 2000;
|
||||
[JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; } = 30;
|
||||
[JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; } = 120;
|
||||
}
|
||||
}
|
||||
232
MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs
Normal file
232
MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.WebAPI.AutoDetect;
|
||||
using Dalamud.Plugin.Services;
|
||||
using System.Numerics;
|
||||
using System.Linq;
|
||||
using MareSynchronos.Utils;
|
||||
|
||||
namespace MareSynchronos.Services.AutoDetect;
|
||||
|
||||
public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
private readonly ILogger<NearbyDiscoveryService> _logger;
|
||||
private readonly MareMediator _mediator;
|
||||
private readonly MareConfigService _config;
|
||||
private readonly DiscoveryConfigProvider _configProvider;
|
||||
private readonly DalamudUtilService _dalamud;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly DiscoveryApiClient _api;
|
||||
private CancellationTokenSource? _loopCts;
|
||||
private string? _lastPublishedSignature;
|
||||
private bool _loggedLocalOnly;
|
||||
private int _lastLocalCount = -1;
|
||||
private int _lastMatchCount = -1;
|
||||
|
||||
public NearbyDiscoveryService(ILogger<NearbyDiscoveryService> logger, MareMediator mediator,
|
||||
MareConfigService config, DiscoveryConfigProvider configProvider, DalamudUtilService dalamudUtilService,
|
||||
IObjectTable objectTable, DiscoveryApiClient api)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
_config = config;
|
||||
_configProvider = configProvider;
|
||||
_dalamud = dalamudUtilService;
|
||||
_objectTable = objectTable;
|
||||
_api = api;
|
||||
}
|
||||
|
||||
public MareMediator Mediator => _mediator;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_loopCts = new CancellationTokenSource();
|
||||
_mediator.Subscribe<ConnectedMessage>(this, _ => _configProvider.TryLoadFromStapled());
|
||||
_ = Task.Run(() => Loop(_loopCts.Token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_mediator.UnsubscribeAll(this);
|
||||
try { _loopCts?.Cancel(); } catch { }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task Loop(CancellationToken ct)
|
||||
{
|
||||
// best effort config load
|
||||
_configProvider.TryLoadFromStapled();
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn)
|
||||
{
|
||||
await Task.Delay(1000, ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure we have a valid Nearby config: try stapled, then HTTP fallback
|
||||
if (!_configProvider.HasConfig || _configProvider.IsExpired())
|
||||
{
|
||||
if (!_configProvider.TryLoadFromStapled())
|
||||
{
|
||||
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = await GetLocalNearbyAsync().ConfigureAwait(false);
|
||||
// Log when local count changes (including 0) to indicate activity
|
||||
if (entries.Count != _lastLocalCount)
|
||||
{
|
||||
_lastLocalCount = entries.Count;
|
||||
_logger.LogInformation("Nearby: {count} players detected locally", _lastLocalCount);
|
||||
}
|
||||
|
||||
// Try query server if config and endpoints are present
|
||||
if (_configProvider.NearbyEnabled && !_configProvider.IsExpired() &&
|
||||
_configProvider.Salt is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
var saltHex = Convert.ToHexString(_configProvider.Salt!);
|
||||
// map hash->index for result matching and reuse for publish
|
||||
Dictionary<string, int> hashToIndex = new(StringComparer.Ordinal);
|
||||
List<string> hashes = new(entries.Count);
|
||||
foreach (var (entry, idx) in entries.Select((e, i) => (e, i)))
|
||||
{
|
||||
var h = (saltHex + entry.Name + entry.WorldId.ToString()).GetHash256();
|
||||
hashToIndex[h] = idx;
|
||||
hashes.Add(h);
|
||||
}
|
||||
|
||||
// Publish local snapshot if endpoint is available (deduplicated)
|
||||
if (!string.IsNullOrEmpty(_configProvider.PublishEndpoint))
|
||||
{
|
||||
string? displayName = null;
|
||||
try
|
||||
{
|
||||
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
||||
if (me != null)
|
||||
{
|
||||
displayName = me.Name.TextValue;
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
if (hashes.Count > 0)
|
||||
{
|
||||
var sig = string.Join(',', hashes.OrderBy(s => s, StringComparer.Ordinal)).GetHash256();
|
||||
if (!string.Equals(sig, _lastPublishedSignature, StringComparison.Ordinal))
|
||||
{
|
||||
_lastPublishedSignature = sig;
|
||||
_logger.LogDebug("Nearby publish: {count} hashes (updated)", hashes.Count);
|
||||
_ = _api.PublishAsync(_configProvider.PublishEndpoint!, hashes, displayName, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Nearby publish skipped (no changes)");
|
||||
}
|
||||
}
|
||||
// else: no local entries; skip publish silently
|
||||
}
|
||||
|
||||
// Query for matches if endpoint is available
|
||||
if (!string.IsNullOrEmpty(_configProvider.QueryEndpoint))
|
||||
{
|
||||
// chunked queries
|
||||
int batch = Math.Max(1, _configProvider.MaxQueryBatch);
|
||||
List<ServerMatch> allMatches = new();
|
||||
for (int i = 0; i < hashes.Count; i += batch)
|
||||
{
|
||||
var slice = hashes.Skip(i).Take(batch).ToArray();
|
||||
var res = await _api.QueryAsync(_configProvider.QueryEndpoint!, slice, ct).ConfigureAwait(false);
|
||||
if (res != null && res.Count > 0) allMatches.AddRange(res);
|
||||
}
|
||||
|
||||
if (allMatches.Count > 0)
|
||||
{
|
||||
foreach (var m in allMatches)
|
||||
{
|
||||
if (hashToIndex.TryGetValue(m.Hash, out var idx))
|
||||
{
|
||||
var e = entries[idx];
|
||||
entries[idx] = new NearbyEntry(e.Name, e.WorldId, e.Distance, true, m.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log change in number of Umbra matches
|
||||
int matchCount = entries.Count(e => e.IsMatch);
|
||||
if (matchCount != _lastMatchCount)
|
||||
{
|
||||
_lastMatchCount = matchCount;
|
||||
_logger.LogInformation("Nearby: {count} Umbra users nearby", matchCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Nearby query failed; falling back to local list");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_loggedLocalOnly)
|
||||
{
|
||||
_loggedLocalOnly = true;
|
||||
_logger.LogInformation("Nearby: well-known not available or disabled; running in local-only mode");
|
||||
}
|
||||
}
|
||||
_mediator.Publish(new DiscoveryListUpdated(entries));
|
||||
|
||||
var delayMs = Math.Max(1000, _configProvider.MinQueryIntervalMs);
|
||||
await Task.Delay(delayMs, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "NearbyDiscoveryService loop error");
|
||||
await Task.Delay(2000, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<NearbyEntry>> GetLocalNearbyAsync()
|
||||
{
|
||||
var list = new List<NearbyEntry>();
|
||||
try
|
||||
{
|
||||
var local = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
||||
var localPos = local?.Position ?? Vector3.Zero;
|
||||
int maxDist = Math.Clamp(_config.Current.AutoDetectMaxDistanceMeters, 5, 100);
|
||||
|
||||
for (int i = 0; i < 200; i += 2)
|
||||
{
|
||||
var obj = await _dalamud.RunOnFrameworkThread(() => _objectTable[i]).ConfigureAwait(false);
|
||||
if (obj == null || obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue;
|
||||
if (local != null && obj.Address == local.Address) continue;
|
||||
|
||||
float dist = local == null ? float.NaN : Vector3.Distance(localPos, obj.Position);
|
||||
if (!float.IsNaN(dist) && dist > maxDist) continue;
|
||||
|
||||
string name = obj.Name.ToString();
|
||||
ushort worldId = 0;
|
||||
if (obj is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter pc)
|
||||
worldId = (ushort)pc.HomeWorld.RowId;
|
||||
|
||||
list.Add(new NearbyEntry(name, worldId, dist, false, null));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,8 @@ namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
private const string _commandName = "/sync";
|
||||
private const string _commandName2 = "/usync";
|
||||
|
||||
private const string _ssCommandPrefix = "/ss";
|
||||
private const string _commandName = "/usync";
|
||||
private const string _ssCommandPrefix = "/ums";
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ICommandManager _commandManager;
|
||||
@@ -42,11 +40,7 @@ public sealed class CommandManagerService : IDisposable
|
||||
_mareConfigService = mareConfigService;
|
||||
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Umbra UI"
|
||||
});
|
||||
_commandManager.AddHandler(_commandName2, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Umbra UI"
|
||||
HelpMessage = "Opens the UmbraSync UI"
|
||||
});
|
||||
|
||||
// Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway
|
||||
@@ -62,7 +56,7 @@ public sealed class CommandManagerService : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_commandManager.RemoveHandler(_commandName);
|
||||
_commandManager.RemoveHandler(_commandName2);
|
||||
|
||||
|
||||
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
|
||||
_commandManager.RemoveHandler($"{_ssCommandPrefix}{i}");
|
||||
@@ -147,7 +141,6 @@ public sealed class CommandManagerService : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
// FIXME: Chat content seems to already be stripped of any special characters here?
|
||||
byte[] chatBytes = Encoding.UTF8.GetBytes(args);
|
||||
_chatService.SendChatShell(shellNumber, chatBytes);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ using System.Numerics;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
#pragma warning disable MA0048 // File name must match type name
|
||||
#pragma warning disable MA0048
|
||||
#pragma warning disable S2094
|
||||
public record SwitchToIntroUiMessage : MessageBase;
|
||||
public record SwitchToMainUiMessage : MessageBase;
|
||||
@@ -108,6 +108,9 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
|
||||
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
|
||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||
|
||||
public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMatch, string? Token);
|
||||
public record DiscoveryListUpdated(List<NearbyEntry> Entries) : MessageBase;
|
||||
|
||||
public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName);
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
#pragma warning restore MA0048
|
||||
Reference in New Issue
Block a user