mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2025-04-23 04:15:47 +02:00
Invalidate Web authorization when a user is updated/deleted
This commit is contained in:
parent
3ffb37529c
commit
f683a1f700
Common/Phantom.Common.Messages.Web
Controller/Phantom.Controller.Services
Web/Phantom.Web.Services
@ -0,0 +1,8 @@
|
||||
using MemoryPack;
|
||||
|
||||
namespace Phantom.Common.Messages.Web.ToWeb;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record RefreshUserSessionMessage(
|
||||
[property: MemoryPackOrder(0)] Guid UserGuid
|
||||
) : IMessageToWeb;
|
@ -48,6 +48,7 @@ public static class WebMessageRegistries {
|
||||
ToWeb.Add<RefreshAgentsMessage>(1);
|
||||
ToWeb.Add<RefreshInstancesMessage>(2);
|
||||
ToWeb.Add<InstanceOutputMessage>(3);
|
||||
ToWeb.Add<RefreshUserSessionMessage>(4);
|
||||
ToWeb.Add<ReplyMessage>(127);
|
||||
}
|
||||
|
||||
|
@ -54,9 +54,9 @@ public sealed class ControllerServices : IDisposable {
|
||||
this.MinecraftVersions = new MinecraftVersions();
|
||||
|
||||
this.AuthenticatedUserCache = new AuthenticatedUserCache();
|
||||
this.UserManager = new UserManager(AuthenticatedUserCache, dbProvider);
|
||||
this.UserManager = new UserManager(AuthenticatedUserCache, ControllerState, dbProvider);
|
||||
this.RoleManager = new RoleManager(dbProvider);
|
||||
this.UserRoleManager = new UserRoleManager(AuthenticatedUserCache, dbProvider);
|
||||
this.UserRoleManager = new UserRoleManager(AuthenticatedUserCache, ControllerState, dbProvider);
|
||||
this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, UserManager, dbProvider);
|
||||
this.PermissionManager = new PermissionManager(dbProvider);
|
||||
|
||||
|
@ -19,6 +19,8 @@ sealed class ControllerState {
|
||||
public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide;
|
||||
public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide;
|
||||
|
||||
public event EventHandler<Guid>? UserUpdatedOrDeleted;
|
||||
|
||||
public void UpdateAgent(Agent agent) {
|
||||
agentsByGuid.PublisherSide.Publish(static (agentsByGuid, agent) => agentsByGuid.SetItem(agent.AgentGuid, agent), agent);
|
||||
}
|
||||
@ -30,4 +32,8 @@ sealed class ControllerState {
|
||||
public void UpdateInstance(Instance instance) {
|
||||
instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
|
||||
}
|
||||
|
||||
public void UpdateOrDeleteUser(Guid userGuid) {
|
||||
UserUpdatedOrDeleted?.Invoke(null, userGuid);
|
||||
}
|
||||
}
|
||||
|
@ -30,22 +30,31 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
|
||||
ReceiveAsync<RefreshAgentsCommand>(RefreshAgents);
|
||||
ReceiveAsync<RefreshInstancesCommand>(RefreshInstances);
|
||||
ReceiveAsync<ReceiveInstanceLogsCommand>(ReceiveInstanceLogs);
|
||||
ReceiveAsync<RefreshUserSessionCommand>(RefreshUserSession);
|
||||
}
|
||||
|
||||
protected override void PreStart() {
|
||||
controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state));
|
||||
controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state));
|
||||
|
||||
|
||||
controllerState.UserUpdatedOrDeleted += OnUserUpdatedOrDeleted;
|
||||
|
||||
instanceLogManager.LogsReceived += OnInstanceLogsReceived;
|
||||
}
|
||||
|
||||
protected override void PostStop() {
|
||||
instanceLogManager.LogsReceived -= OnInstanceLogsReceived;
|
||||
|
||||
controllerState.UserUpdatedOrDeleted -= OnUserUpdatedOrDeleted;
|
||||
|
||||
controllerState.AgentsByGuidReceiver.Unregister(SelfTyped);
|
||||
controllerState.InstancesByGuidReceiver.Unregister(SelfTyped);
|
||||
}
|
||||
|
||||
private void OnUserUpdatedOrDeleted(object? sender, Guid userGuid) {
|
||||
selfCached.Tell(new RefreshUserSessionCommand(userGuid));
|
||||
}
|
||||
|
||||
private void OnInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
|
||||
selfCached.Tell(new ReceiveInstanceLogsCommand(e.InstanceGuid, e.Lines));
|
||||
}
|
||||
@ -57,6 +66,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
|
||||
private sealed record RefreshInstancesCommand(ImmutableDictionary<Guid, Instance> Instances) : ICommand;
|
||||
|
||||
private sealed record ReceiveInstanceLogsCommand(Guid InstanceGuid, ImmutableArray<string> Lines) : ICommand;
|
||||
|
||||
private sealed record RefreshUserSessionCommand(Guid UserGuid) : ICommand;
|
||||
|
||||
private Task RefreshAgents(RefreshAgentsCommand command) {
|
||||
return connection.Send(new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray()));
|
||||
@ -69,4 +80,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
|
||||
private Task ReceiveInstanceLogs(ReceiveInstanceLogsCommand command) {
|
||||
return connection.Send(new InstanceOutputMessage(command.InstanceGuid, command.Lines));
|
||||
}
|
||||
|
||||
private Task RefreshUserSession(RefreshUserSessionCommand command) {
|
||||
return connection.Send(new RefreshUserSessionMessage(command.UserGuid));
|
||||
}
|
||||
}
|
||||
|
@ -14,10 +14,12 @@ sealed class UserManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
|
||||
|
||||
private readonly AuthenticatedUserCache authenticatedUserCache;
|
||||
private readonly ControllerState controllerState;
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
|
||||
public UserManager(AuthenticatedUserCache authenticatedUserCache, IDbContextProvider dbProvider) {
|
||||
public UserManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
|
||||
this.authenticatedUserCache = authenticatedUserCache;
|
||||
this.controllerState = controllerState;
|
||||
this.dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
@ -140,6 +142,7 @@ sealed class UserManager {
|
||||
|
||||
// In case the user logged in during deletion.
|
||||
authenticatedUserCache.Remove(userGuid);
|
||||
controllerState.UpdateOrDeleteUser(userGuid);
|
||||
|
||||
Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return DeleteUserResult.Deleted;
|
||||
|
@ -13,10 +13,12 @@ sealed class UserRoleManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
|
||||
|
||||
private readonly AuthenticatedUserCache authenticatedUserCache;
|
||||
private readonly ControllerState controllerState;
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
|
||||
public UserRoleManager(AuthenticatedUserCache authenticatedUserCache, IDbContextProvider dbProvider) {
|
||||
public UserRoleManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
|
||||
this.authenticatedUserCache = authenticatedUserCache;
|
||||
this.controllerState = controllerState;
|
||||
this.dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
@ -49,7 +51,7 @@ sealed class UserRoleManager {
|
||||
|
||||
var removedFromRoleGuids = ImmutableHashSet.CreateBuilder<Guid>();
|
||||
var removedFromRoleNames = new List<string>();
|
||||
|
||||
|
||||
try {
|
||||
foreach (var roleGuid in addToRoleGuids) {
|
||||
if (rolesByGuid.TryGetValue(roleGuid, out var role)) {
|
||||
@ -71,6 +73,7 @@ sealed class UserRoleManager {
|
||||
await db.Ctx.SaveChangesAsync();
|
||||
|
||||
await authenticatedUserCache.Update(user, db);
|
||||
controllerState.UpdateOrDeleteUser(user.UserGuid);
|
||||
|
||||
Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable());
|
||||
|
@ -4,42 +4,118 @@ using Microsoft.AspNetCore.Components.Server;
|
||||
using Phantom.Common.Data;
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using Phantom.Common.Messages.Web.ToController;
|
||||
using Phantom.Utils.Logging;
|
||||
using Phantom.Web.Services.Rpc;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Web.Services.Authentication;
|
||||
|
||||
public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider {
|
||||
public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider, IAsyncDisposable {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<CustomAuthenticationStateProvider>();
|
||||
|
||||
private readonly UserSessionRefreshManager sessionRefreshManager;
|
||||
private readonly UserSessionBrowserStorage sessionBrowserStorage;
|
||||
private readonly ControllerConnection controllerConnection;
|
||||
private bool isLoaded;
|
||||
|
||||
public CustomAuthenticationStateProvider(UserSessionBrowserStorage sessionBrowserStorage, ControllerConnection controllerConnection) {
|
||||
private readonly SemaphoreSlim loadSemaphore = new (1);
|
||||
private bool isLoaded = false;
|
||||
private CancellationTokenSource? loadCancellationTokenSource;
|
||||
private UserSessionRefreshManager.EventHolder? userRefreshEventHolder;
|
||||
|
||||
public CustomAuthenticationStateProvider(UserSessionRefreshManager sessionRefreshManager, UserSessionBrowserStorage sessionBrowserStorage, ControllerConnection controllerConnection) {
|
||||
this.sessionRefreshManager = sessionRefreshManager;
|
||||
this.sessionBrowserStorage = sessionBrowserStorage;
|
||||
this.controllerConnection = controllerConnection;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
|
||||
if (!isLoaded) {
|
||||
var stored = await sessionBrowserStorage.Get();
|
||||
if (stored != null) {
|
||||
var authToken = stored.Token;
|
||||
var session = await controllerConnection.Send<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(new GetAuthenticatedUser(stored.UserGuid, authToken), TimeSpan.FromSeconds(30));
|
||||
if (session.Value is {} userInfo) {
|
||||
SetLoadedSession(new AuthenticatedUser(userInfo, authToken));
|
||||
}
|
||||
}
|
||||
await LoadSession();
|
||||
}
|
||||
|
||||
return await base.GetAuthenticationStateAsync();
|
||||
}
|
||||
|
||||
internal void SetLoadedSession(AuthenticatedUser authenticatedUser) {
|
||||
isLoaded = true;
|
||||
private async Task LoadSession() {
|
||||
await CancelCurrentLoad();
|
||||
await loadSemaphore.WaitAsync(CancellationToken.None);
|
||||
|
||||
loadCancellationTokenSource = new CancellationTokenSource();
|
||||
CancellationToken cancellationToken = loadCancellationTokenSource.Token;
|
||||
|
||||
try {
|
||||
var authenticatedUser = await TryGetSession(cancellationToken);
|
||||
if (authenticatedUser != null) {
|
||||
SetLoadedSession(authenticatedUser);
|
||||
}
|
||||
else {
|
||||
SetUnloadedSession();
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
SetUnloadedSession();
|
||||
} catch (Exception e) {
|
||||
SetUnloadedSession();
|
||||
Logger.Error(e, "Could not load user session.");
|
||||
} finally {
|
||||
loadCancellationTokenSource.Dispose();
|
||||
loadCancellationTokenSource = null;
|
||||
loadSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelCurrentLoad() {
|
||||
var cancellationTokenSource = loadCancellationTokenSource;
|
||||
if (cancellationTokenSource != null) {
|
||||
await cancellationTokenSource.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AuthenticatedUser?> TryGetSession(CancellationToken cancellationToken) {
|
||||
var stored = await sessionBrowserStorage.Get();
|
||||
if (stored == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var userGuid = stored.UserGuid;
|
||||
var authToken = stored.Token;
|
||||
|
||||
if (userRefreshEventHolder == null) {
|
||||
userRefreshEventHolder = sessionRefreshManager.GetEventHolder(userGuid);
|
||||
userRefreshEventHolder.UserNeedsRefresh += OnUserNeedsRefresh;
|
||||
}
|
||||
|
||||
var session = await controllerConnection.Send<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(new GetAuthenticatedUser(userGuid, authToken), TimeSpan.FromSeconds(30), cancellationToken);
|
||||
if (session.Value is {} userInfo) {
|
||||
return new AuthenticatedUser(userInfo, authToken);
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLoadedSession(AuthenticatedUser authenticatedUser) {
|
||||
SetAuthenticationState(Task.FromResult(new AuthenticationState(new CustomClaimsPrincipal(authenticatedUser))));
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
internal void SetUnloadedSession() {
|
||||
isLoaded = false;
|
||||
SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
|
||||
isLoaded = false;
|
||||
}
|
||||
|
||||
private void OnUserNeedsRefresh(object? sender, EventArgs args) {
|
||||
_ = LoadSession();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (userRefreshEventHolder != null) {
|
||||
userRefreshEventHolder.UserNeedsRefresh -= OnUserNeedsRefresh;
|
||||
userRefreshEventHolder = null;
|
||||
}
|
||||
|
||||
await CancelCurrentLoad();
|
||||
loadSemaphore.Dispose();
|
||||
}
|
||||
}
|
||||
|
@ -39,8 +39,9 @@ public sealed class UserLoginManager {
|
||||
var userInfo = success.UserInfo;
|
||||
var authToken = success.AuthToken;
|
||||
|
||||
authenticationStateProvider.SetUnloadedSession();
|
||||
await sessionBrowserStorage.Store(userInfo.Guid, authToken);
|
||||
authenticationStateProvider.SetLoadedSession(new AuthenticatedUser(userInfo, authToken));
|
||||
await authenticationStateProvider.GetAuthenticationStateAsync();
|
||||
await navigation.NavigateTo(returnUrl ?? string.Empty);
|
||||
|
||||
return true;
|
||||
|
@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Phantom.Web.Services.Authentication;
|
||||
|
||||
public sealed class UserSessionRefreshManager {
|
||||
private readonly ConcurrentDictionary<Guid, EventHolder> userUpdateEventHoldersByUserGuid = new ();
|
||||
|
||||
internal EventHolder GetEventHolder(Guid userGuid) {
|
||||
return userUpdateEventHoldersByUserGuid.GetOrAdd(userGuid, static _ => new EventHolder());
|
||||
}
|
||||
|
||||
internal void RefreshUser(Guid userGuid) {
|
||||
if (userUpdateEventHoldersByUserGuid.TryGetValue(userGuid, out var eventHolder)) {
|
||||
eventHolder.Notify();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EventHolder {
|
||||
public event EventHandler? UserNeedsRefresh;
|
||||
|
||||
internal void Notify() {
|
||||
UserNeedsRefresh?.Invoke(null, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
@ -23,6 +23,7 @@ public static class PhantomWebServices {
|
||||
|
||||
services.AddSingleton<UserManager>();
|
||||
services.AddSingleton<AuditLogManager>();
|
||||
services.AddSingleton<UserSessionRefreshManager>();
|
||||
services.AddScoped<UserLoginManager>();
|
||||
services.AddScoped<UserSessionBrowserStorage>();
|
||||
|
||||
|
@ -4,12 +4,20 @@ using Phantom.Common.Messages.Web.ToWeb;
|
||||
using Phantom.Utils.Actor;
|
||||
using Phantom.Utils.Rpc.Runtime;
|
||||
using Phantom.Web.Services.Agents;
|
||||
using Phantom.Web.Services.Authentication;
|
||||
using Phantom.Web.Services.Instances;
|
||||
|
||||
namespace Phantom.Web.Services.Rpc;
|
||||
|
||||
sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
|
||||
public readonly record struct Init(RpcConnectionToServer<IMessageToController> Connection, AgentManager AgentManager, InstanceManager InstanceManager, InstanceLogManager InstanceLogManager, TaskCompletionSource<bool> RegisterSuccessWaiter);
|
||||
public readonly record struct Init(
|
||||
RpcConnectionToServer<IMessageToController> Connection,
|
||||
AgentManager AgentManager,
|
||||
InstanceManager InstanceManager,
|
||||
InstanceLogManager InstanceLogManager,
|
||||
UserSessionRefreshManager UserSessionRefreshManager,
|
||||
TaskCompletionSource<bool> RegisterSuccessWaiter
|
||||
);
|
||||
|
||||
public static Props<IMessageToWeb> Factory(Init init) {
|
||||
return Props<IMessageToWeb>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
|
||||
@ -19,6 +27,7 @@ sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
|
||||
private readonly AgentManager agentManager;
|
||||
private readonly InstanceManager instanceManager;
|
||||
private readonly InstanceLogManager instanceLogManager;
|
||||
private readonly UserSessionRefreshManager userSessionRefreshManager;
|
||||
private readonly TaskCompletionSource<bool> registerSuccessWaiter;
|
||||
|
||||
private ControllerMessageHandlerActor(Init init) {
|
||||
@ -26,12 +35,14 @@ sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
|
||||
this.agentManager = init.AgentManager;
|
||||
this.instanceManager = init.InstanceManager;
|
||||
this.instanceLogManager = init.InstanceLogManager;
|
||||
this.userSessionRefreshManager = init.UserSessionRefreshManager;
|
||||
this.registerSuccessWaiter = init.RegisterSuccessWaiter;
|
||||
|
||||
Receive<RegisterWebResultMessage>(HandleRegisterWebResult);
|
||||
Receive<RefreshAgentsMessage>(HandleRefreshAgents);
|
||||
Receive<RefreshInstancesMessage>(HandleRefreshInstances);
|
||||
Receive<InstanceOutputMessage>(HandleInstanceOutput);
|
||||
Receive<RefreshUserSessionMessage>(HandleRefreshUserSession);
|
||||
Receive<ReplyMessage>(HandleReply);
|
||||
}
|
||||
|
||||
@ -51,6 +62,10 @@ sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
|
||||
instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
|
||||
}
|
||||
|
||||
private void HandleRefreshUserSession(RefreshUserSessionMessage message) {
|
||||
userSessionRefreshManager.RefreshUser(message.UserGuid);
|
||||
}
|
||||
|
||||
private void HandleReply(ReplyMessage message) {
|
||||
connection.Receive(message);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ using Phantom.Utils.Actor;
|
||||
using Phantom.Utils.Rpc.Runtime;
|
||||
using Phantom.Utils.Tasks;
|
||||
using Phantom.Web.Services.Agents;
|
||||
using Phantom.Web.Services.Authentication;
|
||||
using Phantom.Web.Services.Instances;
|
||||
|
||||
namespace Phantom.Web.Services.Rpc;
|
||||
@ -13,6 +14,7 @@ public sealed class ControllerMessageHandlerFactory {
|
||||
private readonly AgentManager agentManager;
|
||||
private readonly InstanceManager instanceManager;
|
||||
private readonly InstanceLogManager instanceLogManager;
|
||||
private readonly UserSessionRefreshManager userSessionRefreshManager;
|
||||
|
||||
private readonly TaskCompletionSource<bool> registerSuccessWaiter = AsyncTasks.CreateCompletionSource<bool>();
|
||||
|
||||
@ -20,15 +22,17 @@ public sealed class ControllerMessageHandlerFactory {
|
||||
|
||||
private int messageHandlerId = 0;
|
||||
|
||||
public ControllerMessageHandlerFactory(RpcConnectionToServer<IMessageToController> connection, AgentManager agentManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager) {
|
||||
public ControllerMessageHandlerFactory(RpcConnectionToServer<IMessageToController> connection, AgentManager agentManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, UserSessionRefreshManager userSessionRefreshManager) {
|
||||
this.connection = connection;
|
||||
this.agentManager = agentManager;
|
||||
this.instanceManager = instanceManager;
|
||||
this.instanceLogManager = instanceLogManager;
|
||||
this.userSessionRefreshManager = userSessionRefreshManager;
|
||||
}
|
||||
|
||||
public ActorRef<IMessageToWeb> Create(IActorRefFactory actorSystem) {
|
||||
int id = Interlocked.Increment(ref messageHandlerId);
|
||||
return actorSystem.ActorOf(ControllerMessageHandlerActor.Factory(new ControllerMessageHandlerActor.Init(connection, agentManager, instanceManager, instanceLogManager, registerSuccessWaiter)), "ControllerMessageHandler-" + id);
|
||||
var init = new ControllerMessageHandlerActor.Init(connection, agentManager, instanceManager, instanceLogManager, userSessionRefreshManager, registerSuccessWaiter);
|
||||
var name = "ControllerMessageHandler-" + Interlocked.Increment(ref messageHandlerId);
|
||||
return actorSystem.ActorOf(ControllerMessageHandlerActor.Factory(init), name);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user