mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-11-04 03:40:15 +01:00 
			
		
		
		
	Compare commits
	
		
			16 Commits
		
	
	
		
			4c3b81c54a
			...
			wip-forge
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						35ca896849
	
				 | 
					
					
						|||
| 
						
						
							
						
						30b3ba60cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8149d31d51
	
				 | 
					
					
						|||
| 
						
						
							
						
						9873f8779f
	
				 | 
					
					
						|||
| 
						
						
							
						
						31e101b21e
	
				 | 
					
					
						|||
| 
						
						
							
						
						398bb14742
	
				 | 
					
					
						|||
| 
						
						
							
						
						4e89d7b12f
	
				 | 
					
					
						|||
| 
						
						
							
						
						3b313bf0a8
	
				 | 
					
					
						|||
| 
						
						
							
						
						21c90cb7c2
	
				 | 
					
					
						|||
| 
						
						
							
						
						7b68b5d50d
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d70d2b7d1
	
				 | 
					
					
						|||
| 
						
						
							
						
						8c623171f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						62f8c685f0
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a110db078
	
				 | 
					
					
						|||
| 
						
						
							
						
						f683a1f700
	
				 | 
					
					
						|||
| 
						
						
							
						
						3ffb37529c
	
				 | 
					
					
						
@@ -3,7 +3,7 @@
 | 
				
			|||||||
  "isRoot": true,
 | 
					  "isRoot": true,
 | 
				
			||||||
  "tools": {
 | 
					  "tools": {
 | 
				
			||||||
    "dotnet-ef": {
 | 
					    "dotnet-ef": {
 | 
				
			||||||
      "version": "7.0.0-rc.1.22426.7",
 | 
					      "version": "8.0.3",
 | 
				
			||||||
      "commands": [
 | 
					      "commands": [
 | 
				
			||||||
        "dotnet-ef"
 | 
					        "dotnet-ef"
 | 
				
			||||||
      ]
 | 
					      ]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using System.Text;
 | 
					using System.Collections.ObjectModel;
 | 
				
			||||||
 | 
					using System.Text;
 | 
				
			||||||
using Phantom.Agent.Minecraft.Instance;
 | 
					using Phantom.Agent.Minecraft.Instance;
 | 
				
			||||||
using Phantom.Agent.Minecraft.Java;
 | 
					using Phantom.Agent.Minecraft.Java;
 | 
				
			||||||
using Phantom.Agent.Minecraft.Server;
 | 
					using Phantom.Agent.Minecraft.Server;
 | 
				
			||||||
@@ -11,6 +12,7 @@ public abstract class BaseLauncher : IServerLauncher {
 | 
				
			|||||||
	private readonly InstanceProperties instanceProperties;
 | 
						private readonly InstanceProperties instanceProperties;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected string MinecraftVersion => instanceProperties.ServerVersion;
 | 
						protected string MinecraftVersion => instanceProperties.ServerVersion;
 | 
				
			||||||
 | 
						protected string InstanceFolder => instanceProperties.InstanceFolder;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private protected BaseLauncher(InstanceProperties instanceProperties) {
 | 
						private protected BaseLauncher(InstanceProperties instanceProperties) {
 | 
				
			||||||
		this.instanceProperties = instanceProperties;
 | 
							this.instanceProperties = instanceProperties;
 | 
				
			||||||
@@ -51,16 +53,14 @@ public abstract class BaseLauncher : IServerLauncher {
 | 
				
			|||||||
		
 | 
							
 | 
				
			||||||
		var processConfigurator = new ProcessConfigurator {
 | 
							var processConfigurator = new ProcessConfigurator {
 | 
				
			||||||
			FileName = javaRuntimeExecutable.ExecutablePath,
 | 
								FileName = javaRuntimeExecutable.ExecutablePath,
 | 
				
			||||||
			WorkingDirectory = instanceProperties.InstanceFolder,
 | 
								WorkingDirectory = InstanceFolder,
 | 
				
			||||||
			RedirectInput = true,
 | 
								RedirectInput = true,
 | 
				
			||||||
			UseShellExecute = false
 | 
								UseShellExecute = false
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		var processArguments = processConfigurator.ArgumentList;
 | 
							var processArguments = processConfigurator.ArgumentList;
 | 
				
			||||||
		PrepareJvmArguments(serverJar).Build(processArguments);
 | 
							PrepareJvmArguments(serverJar).Build(processArguments);
 | 
				
			||||||
		processArguments.Add("-jar");
 | 
							PrepareJavaProcessArguments(processArguments, serverJar.FilePath);
 | 
				
			||||||
		processArguments.Add(serverJar.FilePath);
 | 
					 | 
				
			||||||
		processArguments.Add("nogui");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var process = processConfigurator.CreateProcess();
 | 
							var process = processConfigurator.CreateProcess();
 | 
				
			||||||
		var instanceProcess = new InstanceProcess(instanceProperties, process);
 | 
							var instanceProcess = new InstanceProcess(instanceProperties, process);
 | 
				
			||||||
@@ -99,6 +99,12 @@ public abstract class BaseLauncher : IServerLauncher {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
 | 
						private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						protected virtual void PrepareJavaProcessArguments(Collection<string> processArguments, string serverJarFilePath) {
 | 
				
			||||||
 | 
							processArguments.Add("-jar");
 | 
				
			||||||
 | 
							processArguments.Add(serverJarFilePath);
 | 
				
			||||||
 | 
							processArguments.Add("nogui");
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
 | 
						private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
 | 
				
			||||||
		return Task.FromResult(new ServerJarInfo(serverJarPath));
 | 
							return Task.FromResult(new ServerJarInfo(serverJarPath));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					using System.Collections.ObjectModel;
 | 
				
			||||||
 | 
					using Phantom.Agent.Minecraft.Instance;
 | 
				
			||||||
 | 
					using Phantom.Agent.Minecraft.Java;
 | 
				
			||||||
 | 
					using Serilog;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Agent.Minecraft.Launcher.Types; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed class ForgeLauncher : BaseLauncher {
 | 
				
			||||||
 | 
						public ForgeLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private protected override void CustomizeJvmArguments(JvmArgumentBuilder arguments) {
 | 
				
			||||||
 | 
							arguments.AddProperty("terminal.ansi", "true"); // TODO
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						protected override void PrepareJavaProcessArguments(Collection<string> processArguments, string serverJarFilePath) {
 | 
				
			||||||
 | 
							if (OperatingSystem.IsWindows()) {
 | 
				
			||||||
 | 
								processArguments.Add("@libraries/net/minecraftforge/forge/1.20.1-47.2.0/win_args.txt");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								processArguments.Add("@libraries/net/minecraftforge/forge/1.20.1-47.2.0/unix_args.txt");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							processArguments.Add("nogui");
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private protected override Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
 | 
				
			||||||
 | 
							return Task.FromResult(new ServerJarInfo(Path.Combine(InstanceFolder, "run.sh")));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -18,4 +18,5 @@ static class MinecraftServerProperties {
 | 
				
			|||||||
	public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
 | 
						public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
 | 
				
			||||||
	public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
 | 
						public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
 | 
				
			||||||
	public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
 | 
						public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
 | 
				
			||||||
 | 
						public static readonly MinecraftServerProperty<bool> SyncChunkWrites = new Boolean("sync-chunk-writes");
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,11 +5,13 @@ namespace Phantom.Agent.Minecraft.Properties;
 | 
				
			|||||||
public sealed record ServerProperties(
 | 
					public sealed record ServerProperties(
 | 
				
			||||||
	ushort ServerPort,
 | 
						ushort ServerPort,
 | 
				
			||||||
	ushort RconPort,
 | 
						ushort RconPort,
 | 
				
			||||||
	bool EnableRcon = true
 | 
						bool EnableRcon = true,
 | 
				
			||||||
 | 
						bool SyncChunkWrites = false
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
	internal void SetTo(JavaPropertiesFileEditor properties) {
 | 
						internal void SetTo(JavaPropertiesFileEditor properties) {
 | 
				
			||||||
		MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
 | 
							MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
 | 
				
			||||||
		MinecraftServerProperties.RconPort.Set(properties, RconPort);
 | 
							MinecraftServerProperties.RconPort.Set(properties, RconPort);
 | 
				
			||||||
		MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
 | 
							MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
 | 
				
			||||||
 | 
							MinecraftServerProperties.SyncChunkWrites.Set(properties, SyncChunkWrites);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,28 +3,12 @@ using System.Buffers.Binary;
 | 
				
			|||||||
using System.Net;
 | 
					using System.Net;
 | 
				
			||||||
using System.Net.Sockets;
 | 
					using System.Net.Sockets;
 | 
				
			||||||
using System.Text;
 | 
					using System.Text;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Common.Data.Instance;
 | 
				
			||||||
using Serilog;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Agent.Minecraft.Server;
 | 
					namespace Phantom.Agent.Minecraft.Server;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public sealed class ServerStatusProtocol {
 | 
					public static class ServerStatusProtocol {
 | 
				
			||||||
	private readonly ILogger logger;
 | 
						public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
 | 
				
			||||||
 | 
					 | 
				
			||||||
	public ServerStatusProtocol(string loggerName) {
 | 
					 | 
				
			||||||
		this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
 | 
					 | 
				
			||||||
		} catch (Exception e) {
 | 
					 | 
				
			||||||
			logger.Error(e, "Caught exception while checking if players are online.");
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
 | 
					 | 
				
			||||||
		using var tcpClient = new TcpClient();
 | 
							using var tcpClient = new TcpClient();
 | 
				
			||||||
		await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
 | 
							await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
 | 
				
			||||||
		var tcpStream = tcpClient.GetStream();
 | 
							var tcpStream = tcpClient.GetStream();
 | 
				
			||||||
@@ -33,24 +17,22 @@ public sealed class ServerStatusProtocol {
 | 
				
			|||||||
		tcpStream.WriteByte(0xFE);
 | 
							tcpStream.WriteByte(0xFE);
 | 
				
			||||||
		await tcpStream.FlushAsync(cancellationToken);
 | 
							await tcpStream.FlushAsync(cancellationToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
 | 
							short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
 | 
				
			||||||
		return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken);
 | 
							return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
 | 
						private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
 | 
				
			||||||
		var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
 | 
							var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
 | 
								await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (headerBuffer[0] != 0xFF) {
 | 
								if (headerBuffer[0] != 0xFF) {
 | 
				
			||||||
				logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]);
 | 
									throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]);
 | 
				
			||||||
				return null;
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
 | 
								short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
 | 
				
			||||||
			if (messageLength <= 0) {
 | 
								if (messageLength <= 0) {
 | 
				
			||||||
				logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength);
 | 
									throw new ProtocolException("Unexpected message length in response from server: " + messageLength);
 | 
				
			||||||
				return null;
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			return messageLength;
 | 
								return messageLength;
 | 
				
			||||||
@@ -59,35 +41,54 @@ public sealed class ServerStatusProtocol {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private async Task<int?> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
 | 
						private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
 | 
				
			||||||
		var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
 | 
							var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
 | 
								await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
 | 
				
			||||||
 | 
								return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength));
 | 
				
			||||||
			// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
 | 
					 | 
				
			||||||
			const byte SeparatorSecondByte = 0xA7;
 | 
					 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
 | 
					 | 
				
			||||||
				return index > 0 && buffer[index - 1] == 0x00;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
 | 
					 | 
				
			||||||
			int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
 | 
					 | 
				
			||||||
			if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
 | 
					 | 
				
			||||||
				logger.Error("Could not find message separators in response from server.");
 | 
					 | 
				
			||||||
				return null;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
 | 
					 | 
				
			||||||
			if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
 | 
					 | 
				
			||||||
				logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
 | 
					 | 
				
			||||||
				return null;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			logger.Debug("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
 | 
					 | 
				
			||||||
			return onlinePlayerCount;
 | 
					 | 
				
			||||||
		} finally {
 | 
							} finally {
 | 
				
			||||||
			ArrayPool<byte>.Shared.Return(messageBuffer);
 | 
								ArrayPool<byte>.Shared.Return(messageBuffer);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						/// <summary>
 | 
				
			||||||
 | 
						/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
 | 
				
			||||||
 | 
						/// </summary>
 | 
				
			||||||
 | 
						private static readonly byte[] Separator = { 0x00, 0xA7 };
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
 | 
				
			||||||
 | 
							int lastSeparator = messageBuffer.LastIndexOf(Separator);
 | 
				
			||||||
 | 
							int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (lastSeparator == -1 || middleSeparator == -1) {
 | 
				
			||||||
 | 
								throw new ProtocolException("Could not find message separators in response from server.");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
 | 
				
			||||||
 | 
							var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// Player counts are integers, whose maximum string length is 10 characters.
 | 
				
			||||||
 | 
							Span<char> integerStringBuffer = stackalloc char[10];
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							return new InstancePlayerCounts(
 | 
				
			||||||
 | 
								DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
 | 
				
			||||||
 | 
								DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
 | 
				
			||||||
 | 
							if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
 | 
				
			||||||
 | 
								throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (!int.TryParse(tempCharBuffer, out int playerCount)) {
 | 
				
			||||||
 | 
								throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							return playerCount;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public sealed class ProtocolException : Exception {
 | 
				
			||||||
 | 
							internal ProtocolException(string message) : base(message) {}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,7 @@ sealed class BackupArchiver {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private bool IsFolderSkipped(ImmutableList<string> relativePath) {
 | 
						private bool IsFolderSkipped(ImmutableList<string> relativePath) {
 | 
				
			||||||
		return relativePath is ["cache" or "crash-reports" or "debug" or "libraries" or "logs" or "mods" or "versions"];
 | 
							return relativePath is ["cache" or "crash-reports" or "debug" or "libraries" or "logs" or "mods" or "servermods" or "versions"];
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
 | 
						[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,8 @@
 | 
				
			|||||||
using Phantom.Agent.Minecraft.Instance;
 | 
					using Phantom.Agent.Services.Instances;
 | 
				
			||||||
using Phantom.Agent.Minecraft.Server;
 | 
					using Phantom.Agent.Services.Instances.State;
 | 
				
			||||||
using Phantom.Agent.Services.Instances;
 | 
					 | 
				
			||||||
using Phantom.Common.Data.Backups;
 | 
					using Phantom.Common.Data.Backups;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Phantom.Utils.Tasks;
 | 
					using Phantom.Utils.Tasks;
 | 
				
			||||||
using Phantom.Utils.Threading;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Agent.Services.Backups;
 | 
					namespace Phantom.Agent.Services.Backups;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -16,20 +14,16 @@ sealed class BackupScheduler : CancellableBackgroundTask {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	private readonly BackupManager backupManager;
 | 
						private readonly BackupManager backupManager;
 | 
				
			||||||
	private readonly InstanceContext context;
 | 
						private readonly InstanceContext context;
 | 
				
			||||||
	private readonly InstanceProcess process;
 | 
					 | 
				
			||||||
	private readonly SemaphoreSlim backupSemaphore = new (1, 1);
 | 
						private readonly SemaphoreSlim backupSemaphore = new (1, 1);
 | 
				
			||||||
	private readonly int serverPort;
 | 
					 | 
				
			||||||
	private readonly ServerStatusProtocol serverStatusProtocol;
 | 
					 | 
				
			||||||
	private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
 | 
						private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
 | 
				
			||||||
 | 
						private readonly InstancePlayerCountTracker playerCountTracker;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public event EventHandler<BackupCreationResult>? BackupCompleted;
 | 
						public event EventHandler<BackupCreationResult>? BackupCompleted;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public BackupScheduler(InstanceContext context, InstanceProcess process, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
 | 
						public BackupScheduler(InstanceContext context, InstancePlayerCountTracker playerCountTracker) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
 | 
				
			||||||
		this.backupManager = context.Services.BackupManager;
 | 
							this.backupManager = context.Services.BackupManager;
 | 
				
			||||||
		this.context = context;
 | 
							this.context = context;
 | 
				
			||||||
		this.process = process;
 | 
							this.playerCountTracker = playerCountTracker;
 | 
				
			||||||
		this.serverPort = serverPort;
 | 
					 | 
				
			||||||
		this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName);
 | 
					 | 
				
			||||||
		Start();
 | 
							Start();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -69,43 +63,18 @@ sealed class BackupScheduler : CancellableBackgroundTask {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private async Task WaitForOnlinePlayers() {
 | 
						private async Task WaitForOnlinePlayers() {
 | 
				
			||||||
		bool needsToLogOfflinePlayersMessage = true;
 | 
							var task = playerCountTracker.WaitForOnlinePlayers(CancellationToken);
 | 
				
			||||||
		
 | 
							if (!task.IsCompleted) {
 | 
				
			||||||
		process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
 | 
								Logger.Information("Waiting for someone to join before starting a new backup.");
 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			while (!CancellationToken.IsCancellationRequested) {
 | 
					 | 
				
			||||||
				serverOutputWhileWaitingForOnlinePlayers.Reset();
 | 
					 | 
				
			||||||
				
 | 
					 | 
				
			||||||
				var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
 | 
					 | 
				
			||||||
				if (onlinePlayerCount == null) {
 | 
					 | 
				
			||||||
					Logger.Warning("Could not detect whether any players are online, starting a new backup.");
 | 
					 | 
				
			||||||
					break;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				if (onlinePlayerCount > 0) {
 | 
					 | 
				
			||||||
					Logger.Information("Players are online, starting a new backup.");
 | 
					 | 
				
			||||||
					break;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				if (needsToLogOfflinePlayersMessage) {
 | 
					 | 
				
			||||||
					needsToLogOfflinePlayersMessage = false;
 | 
					 | 
				
			||||||
					Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
 | 
					 | 
				
			||||||
				
 | 
					 | 
				
			||||||
				Logger.Debug("Waiting for server output before checking for online players again...");
 | 
					 | 
				
			||||||
				await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		} finally {
 | 
					 | 
				
			||||||
			process.RemoveOutputListener(ServerOutputListener);
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
	private void ServerOutputListener(object? sender, string line) {
 | 
							try {
 | 
				
			||||||
		if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
 | 
								await task;
 | 
				
			||||||
			serverOutputWhileWaitingForOnlinePlayers.Set();
 | 
								Logger.Information("Players are online, starting a new backup.");
 | 
				
			||||||
			Logger.Debug("Detected server output, signalling to check for online players again.");
 | 
							} catch (OperationCanceledException) {
 | 
				
			||||||
 | 
								throw;
 | 
				
			||||||
 | 
							} catch (Exception) {
 | 
				
			||||||
 | 
								Logger.Warning("Could not detect whether any players are online, starting a new backup.");
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,7 +55,6 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task SaveAllChunks() {
 | 
						public async Task SaveAllChunks() {
 | 
				
			||||||
		// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
 | 
					 | 
				
			||||||
		await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
 | 
							await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
 | 
				
			||||||
		await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
 | 
							await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -102,6 +102,7 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
 | 
				
			|||||||
		IServerLauncher launcher = configuration.MinecraftServerKind switch {
 | 
							IServerLauncher launcher = configuration.MinecraftServerKind switch {
 | 
				
			||||||
			MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
 | 
								MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
 | 
				
			||||||
			MinecraftServerKind.Fabric  => new FabricLauncher(properties),
 | 
								MinecraftServerKind.Fabric  => new FabricLauncher(properties),
 | 
				
			||||||
 | 
								MinecraftServerKind.Forge   => new ForgeLauncher(properties),
 | 
				
			||||||
			_                           => InvalidLauncher.Instance
 | 
								_                           => InvalidLauncher.Instance
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,140 @@
 | 
				
			|||||||
 | 
					using Phantom.Agent.Minecraft.Instance;
 | 
				
			||||||
 | 
					using Phantom.Agent.Minecraft.Server;
 | 
				
			||||||
 | 
					using Phantom.Agent.Rpc;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Instance;
 | 
				
			||||||
 | 
					using Phantom.Common.Messages.Agent.ToController;
 | 
				
			||||||
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
 | 
					using Phantom.Utils.Tasks;
 | 
				
			||||||
 | 
					using Phantom.Utils.Threading;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Agent.Services.Instances.State;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
 | 
				
			||||||
 | 
						private readonly ControllerConnection controllerConnection;
 | 
				
			||||||
 | 
						private readonly Guid instanceGuid;
 | 
				
			||||||
 | 
						private readonly ushort serverPort;
 | 
				
			||||||
 | 
						private readonly InstanceProcess process;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
 | 
				
			||||||
 | 
						private readonly ManualResetEventSlim serverOutputEvent = new ();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private InstancePlayerCounts? playerCounts;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public InstancePlayerCounts? PlayerCounts {
 | 
				
			||||||
 | 
							get {
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									return playerCounts;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							private set {
 | 
				
			||||||
 | 
								EventHandler<int?>? onlinePlayerCountChanged;
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									if (playerCounts == value) {
 | 
				
			||||||
 | 
										return;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									
 | 
				
			||||||
 | 
									playerCounts = value;
 | 
				
			||||||
 | 
									onlinePlayerCountChanged = OnlinePlayerCountChanged;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								onlinePlayerCountChanged?.Invoke(this, value?.Online);
 | 
				
			||||||
 | 
								controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value));
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private event EventHandler<int?>? OnlinePlayerCountChanged;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private bool isDisposed = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) {
 | 
				
			||||||
 | 
							this.controllerConnection = context.Services.ControllerConnection;
 | 
				
			||||||
 | 
							this.instanceGuid = context.InstanceGuid;
 | 
				
			||||||
 | 
							this.process = process;
 | 
				
			||||||
 | 
							this.serverPort = serverPort;
 | 
				
			||||||
 | 
							Start();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						protected override async Task RunTask() {
 | 
				
			||||||
 | 
							// Give the server time to start accepting connections.
 | 
				
			||||||
 | 
							await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							serverOutputEvent.Set();
 | 
				
			||||||
 | 
							process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							while (!CancellationToken.IsCancellationRequested) {
 | 
				
			||||||
 | 
								serverOutputEvent.Reset();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								PlayerCounts = await TryGetPlayerCounts();
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								if (!firstDetection.Task.IsCompleted) {
 | 
				
			||||||
 | 
									firstDetection.SetResult();
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
 | 
				
			||||||
 | 
								await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
 | 
				
			||||||
 | 
								await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
 | 
				
			||||||
 | 
								Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum);
 | 
				
			||||||
 | 
								return result;
 | 
				
			||||||
 | 
							} catch (ServerStatusProtocol.ProtocolException e) {
 | 
				
			||||||
 | 
								Logger.Error(e.Message);
 | 
				
			||||||
 | 
								return null;
 | 
				
			||||||
 | 
							} catch (Exception e) {
 | 
				
			||||||
 | 
								Logger.Error(e, "Caught exception while checking online player count.");
 | 
				
			||||||
 | 
								return null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
 | 
				
			||||||
 | 
							await firstDetection.Task.WaitAsync(cancellationToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							lock (this) {
 | 
				
			||||||
 | 
								if (playerCounts is { Online: > 0 }) {
 | 
				
			||||||
 | 
									return;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								else if (playerCounts == null) {
 | 
				
			||||||
 | 
									throw new InvalidOperationException();
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								void OnOnlinePlayerCountChanged(object? sender, int? newPlayerCount) {
 | 
				
			||||||
 | 
									if (newPlayerCount == null) {
 | 
				
			||||||
 | 
										onlinePlayersDetected.TrySetException(new InvalidOperationException());
 | 
				
			||||||
 | 
										OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									else if (newPlayerCount > 0) {
 | 
				
			||||||
 | 
										onlinePlayersDetected.TrySetResult();
 | 
				
			||||||
 | 
										OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await onlinePlayersDetected.Task;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private void OnOutput(object? sender, string? line) {
 | 
				
			||||||
 | 
							lock (this) {
 | 
				
			||||||
 | 
								if (!isDisposed) {
 | 
				
			||||||
 | 
									serverOutputEvent.Set();
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						protected override void Dispose() {
 | 
				
			||||||
 | 
							lock (this) {
 | 
				
			||||||
 | 
								isDisposed = true;
 | 
				
			||||||
 | 
								playerCounts = null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							process.RemoveOutputListener(OnOutput);
 | 
				
			||||||
 | 
							serverOutputEvent.Dispose();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -19,6 +19,7 @@ sealed class InstanceRunningState : IDisposable {
 | 
				
			|||||||
	private readonly CancellationToken cancellationToken;
 | 
						private readonly CancellationToken cancellationToken;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private readonly InstanceLogSender logSender;
 | 
						private readonly InstanceLogSender logSender;
 | 
				
			||||||
 | 
						private readonly InstancePlayerCountTracker playerCountTracker;
 | 
				
			||||||
	private readonly BackupScheduler backupScheduler;
 | 
						private readonly BackupScheduler backupScheduler;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private bool isDisposed;
 | 
						private bool isDisposed;
 | 
				
			||||||
@@ -32,8 +33,9 @@ sealed class InstanceRunningState : IDisposable {
 | 
				
			|||||||
		this.cancellationToken = cancellationToken;
 | 
							this.cancellationToken = cancellationToken;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
 | 
							this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
 | 
				
			||||||
 | 
							this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		this.backupScheduler = new BackupScheduler(context, process, configuration.ServerPort);
 | 
							this.backupScheduler = new BackupScheduler(context, playerCountTracker);
 | 
				
			||||||
		this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
 | 
							this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -93,6 +95,11 @@ sealed class InstanceRunningState : IDisposable {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public void OnStopInitiated() {
 | 
				
			||||||
 | 
							backupScheduler.Stop();
 | 
				
			||||||
 | 
							playerCountTracker.Stop();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	private bool TryDispose() {
 | 
						private bool TryDispose() {
 | 
				
			||||||
		lock (this) {
 | 
							lock (this) {
 | 
				
			||||||
			if (isDisposed) {
 | 
								if (isDisposed) {
 | 
				
			||||||
@@ -102,8 +109,8 @@ sealed class InstanceRunningState : IDisposable {
 | 
				
			|||||||
			isDisposed = true;
 | 
								isDisposed = true;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							OnStopInitiated();
 | 
				
			||||||
		logSender.Stop();
 | 
							logSender.Stop();
 | 
				
			||||||
		backupScheduler.Stop();
 | 
					 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		Process.Dispose();
 | 
							Process.Dispose();
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,6 +25,8 @@ static class InstanceStopProcedure {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			// Too late to cancel the stop procedure now.
 | 
								// Too late to cancel the stop procedure now.
 | 
				
			||||||
 | 
								runningState.OnStopInitiated();
 | 
				
			||||||
 | 
								
 | 
				
			||||||
			if (!process.HasEnded) {
 | 
								if (!process.HasEnded) {
 | 
				
			||||||
				context.Logger.Information("Session stopping now.");
 | 
									context.Logger.Information("Session stopping now.");
 | 
				
			||||||
				await DoStop(context, process);
 | 
									await DoStop(context, process);
 | 
				
			||||||
@@ -85,7 +87,7 @@ static class InstanceStopProcedure {
 | 
				
			|||||||
	private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
 | 
						private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			await process.WaitForExit(TimeSpan.FromSeconds(55));
 | 
								await process.WaitForExit(TimeSpan.FromSeconds(55));
 | 
				
			||||||
		} catch (OperationCanceledException) {
 | 
							} catch (TimeoutException) {
 | 
				
			||||||
			try {
 | 
								try {
 | 
				
			||||||
				context.Logger.Warning("Waiting timed out, killing session...");
 | 
									context.Logger.Warning("Waiting timed out, killing session...");
 | 
				
			||||||
				process.Kill();
 | 
									process.Kill();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,9 +8,10 @@ public sealed partial record Instance(
 | 
				
			|||||||
	[property: MemoryPackOrder(0)] Guid InstanceGuid,
 | 
						[property: MemoryPackOrder(0)] Guid InstanceGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
 | 
						[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] IInstanceStatus Status,
 | 
						[property: MemoryPackOrder(2)] IInstanceStatus Status,
 | 
				
			||||||
	[property: MemoryPackOrder(3)] bool LaunchAutomatically
 | 
						[property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts,
 | 
				
			||||||
 | 
						[property: MemoryPackOrder(4)] bool LaunchAutomatically
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
	public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
 | 
						public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
 | 
				
			||||||
		return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically);
 | 
							return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Data.Web.Users;
 | 
					namespace Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -6,5 +7,18 @@ namespace Phantom.Common.Data.Web.Users;
 | 
				
			|||||||
public sealed partial record AuthenticatedUserInfo(
 | 
					public sealed partial record AuthenticatedUserInfo(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid Guid,
 | 
						[property: MemoryPackOrder(0)] Guid Guid,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] string Name,
 | 
						[property: MemoryPackOrder(1)] string Name,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] PermissionSet Permissions
 | 
						[property: MemoryPackOrder(2)] PermissionSet Permissions,
 | 
				
			||||||
);
 | 
						[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> ManagedAgentGuids
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
						public bool CheckPermission(Permission permission) {
 | 
				
			||||||
 | 
							return Permissions.Check(permission);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public bool HasAccessToAgent(Guid agentGuid) {
 | 
				
			||||||
 | 
							return ManagedAgentGuids.Contains(agentGuid) || Permissions.Check(Permission.ManageAllAgents);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) {
 | 
				
			||||||
 | 
							return Permissions.Check(Permission.ManageAllAgents) ? agentGuids : agentGuids.Intersect(ManagedAgentGuids);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,5 +6,5 @@ namespace Phantom.Common.Data.Web.Users;
 | 
				
			|||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record LogInSuccess(
 | 
					public sealed partial record LogInSuccess(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] AuthenticatedUserInfo UserInfo,
 | 
						[property: MemoryPackOrder(0)] AuthenticatedUserInfo UserInfo,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] ImmutableArray<byte> Token
 | 
						[property: MemoryPackOrder(1)] ImmutableArray<byte> AuthToken
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,9 @@ public sealed record Permission(string Id, Permission? Parent) {
 | 
				
			|||||||
		return Register(id, this);
 | 
							return Register(id, this);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public const string ManageAllAgentsPolicy = "Agents.ManageAll";
 | 
				
			||||||
 | 
						public static readonly Permission ManageAllAgents = Register(ManageAllAgentsPolicy);
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	public const string ViewInstancesPolicy = "Instances.View";
 | 
						public const string ViewInstancesPolicy = "Instances.View";
 | 
				
			||||||
	public static readonly Permission ViewInstances = Register(ViewInstancesPolicy);
 | 
						public static readonly Permission ViewInstances = Register(ViewInstancesPolicy);
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					namespace Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public enum UserActionFailure {
 | 
				
			||||||
 | 
						NotAuthorized
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[MemoryPackable]
 | 
				
			||||||
 | 
					[MemoryPackUnion(0, typeof(OfUserActionFailure))]
 | 
				
			||||||
 | 
					[MemoryPackUnion(1, typeof(OfInstanceActionFailure))]
 | 
				
			||||||
 | 
					public abstract partial record UserInstanceActionFailure {
 | 
				
			||||||
 | 
						internal UserInstanceActionFailure() {}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public static implicit operator UserInstanceActionFailure(UserActionFailure failure) {
 | 
				
			||||||
 | 
							return new OfUserActionFailure(failure);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public static implicit operator UserInstanceActionFailure(InstanceActionFailure failure) {
 | 
				
			||||||
 | 
							return new OfInstanceActionFailure(failure);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
 | 
					public sealed partial record OfUserActionFailure([property: MemoryPackOrder(0)] UserActionFailure Failure) : UserInstanceActionFailure;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
 | 
					public sealed partial record OfInstanceActionFailure([property: MemoryPackOrder(0)] InstanceActionFailure Failure) : UserInstanceActionFailure;
 | 
				
			||||||
@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Common.Data.Instance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
 | 
					public readonly partial record struct InstancePlayerCounts(
 | 
				
			||||||
 | 
						[property: MemoryPackOrder(0)] int Online,
 | 
				
			||||||
 | 
						[property: MemoryPackOrder(1)] int Maximum
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
@@ -2,5 +2,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public enum MinecraftServerKind : ushort {
 | 
					public enum MinecraftServerKind : ushort {
 | 
				
			||||||
	Vanilla = 1,
 | 
						Vanilla = 1,
 | 
				
			||||||
	Fabric = 2
 | 
						Fabric = 2,
 | 
				
			||||||
 | 
						Forge = 3
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
using System.Diagnostics.CodeAnalysis;
 | 
					using System.Diagnostics.CodeAnalysis;
 | 
				
			||||||
using MemoryPack;
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Utils.Result;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Data;
 | 
					namespace Phantom.Common.Data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -33,10 +34,18 @@ public sealed partial class Result<TValue, TError> {
 | 
				
			|||||||
		return hasValue && EqualityComparer<TValue>.Default.Equals(value, expectedValue);
 | 
							return hasValue && EqualityComparer<TValue>.Default.Equals(value, expectedValue);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public TOutput Map<TOutput>(Func<TValue, TOutput> valueConverter, Func<TError, TOutput> errorConverter) {
 | 
						public TOutput Into<TOutput>(Func<TValue, TOutput> valueConverter, Func<TError, TOutput> errorConverter) {
 | 
				
			||||||
		return hasValue ? valueConverter(value!) : errorConverter(error!);
 | 
							return hasValue ? valueConverter(value!) : errorConverter(error!);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Result<TValue, TNewError> MapError<TNewError>(Func<TError, TNewError> errorConverter) {
 | 
				
			||||||
 | 
							return hasValue ? value! : errorConverter(error!);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Utils.Result.Result Variant() {
 | 
				
			||||||
 | 
							return hasValue ? new Ok<TValue>(Value) : new Err<TError>(Error);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public static implicit operator Result<TValue, TError>(TValue value) {
 | 
						public static implicit operator Result<TValue, TError>(TValue value) {
 | 
				
			||||||
		return new Result<TValue, TError>(hasValue: true, value, default);
 | 
							return new Result<TValue, TError>(hasValue: true, value, default);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,7 @@ public static class AgentMessageRegistries {
 | 
				
			|||||||
		ToController.Add<InstanceOutputMessage>(5);
 | 
							ToController.Add<InstanceOutputMessage>(5);
 | 
				
			||||||
		ToController.Add<ReportAgentStatusMessage>(6);
 | 
							ToController.Add<ReportAgentStatusMessage>(6);
 | 
				
			||||||
		ToController.Add<ReportInstanceEventMessage>(7);
 | 
							ToController.Add<ReportInstanceEventMessage>(7);
 | 
				
			||||||
 | 
							ToController.Add<ReportInstancePlayerCountsMessage>(8);
 | 
				
			||||||
		ToController.Add<ReplyMessage>(127);
 | 
							ToController.Add<ReplyMessage>(127);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Instance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Common.Messages.Agent.ToController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
 | 
					public sealed partial record ReportInstancePlayerCountsMessage(
 | 
				
			||||||
 | 
						[property: MemoryPackOrder(0)] Guid InstanceGuid,
 | 
				
			||||||
 | 
						[property: MemoryPackOrder(1)] InstancePlayerCounts? PlayerCounts
 | 
				
			||||||
 | 
					) : IMessageToController;
 | 
				
			||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
using MemoryPack;
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -7,8 +8,8 @@ namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record ChangeUserRolesMessage(
 | 
					public sealed partial record ChangeUserRolesMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid SubjectUserGuid,
 | 
						[property: MemoryPackOrder(1)] Guid SubjectUserGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids,
 | 
						[property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids,
 | 
				
			||||||
	[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids
 | 
						[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids
 | 
				
			||||||
) : IMessageToController, ICanReply<ChangeUserRolesResult>;
 | 
					) : IMessageToController, ICanReply<Result<ChangeUserRolesResult, UserActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,16 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Instance;
 | 
					using Phantom.Common.Data.Instance;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					 | 
				
			||||||
using Phantom.Common.Data.Web.Instance;
 | 
					using Phantom.Common.Data.Web.Instance;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController;
 | 
					namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record CreateOrUpdateInstanceMessage(
 | 
					public sealed partial record CreateOrUpdateInstanceMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid InstanceGuid,
 | 
						[property: MemoryPackOrder(1)] Guid InstanceGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] InstanceConfiguration Configuration
 | 
						[property: MemoryPackOrder(2)] InstanceConfiguration Configuration
 | 
				
			||||||
) : IMessageToController, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
 | 
					) : IMessageToController, ICanReply<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,6 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -6,7 +8,7 @@ namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record CreateUserMessage(
 | 
					public sealed partial record CreateUserMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] string Username,
 | 
						[property: MemoryPackOrder(1)] string Username,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] string Password
 | 
						[property: MemoryPackOrder(2)] string Password
 | 
				
			||||||
) : IMessageToController, ICanReply<CreateUserResult>;
 | 
					) : IMessageToController, ICanReply<Result<CreateUserResult, UserActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,6 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -6,6 +8,6 @@ namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record DeleteUserMessage(
 | 
					public sealed partial record DeleteUserMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid SubjectUserGuid
 | 
						[property: MemoryPackOrder(1)] Guid SubjectUserGuid
 | 
				
			||||||
) : IMessageToController, ICanReply<DeleteUserResult>;
 | 
					) : IMessageToController, ICanReply<Result<DeleteUserResult, UserActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,14 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
using MemoryPack;
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.AuditLog;
 | 
					using Phantom.Common.Data.Web.AuditLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController;
 | 
					namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record GetAuditLogMessage(
 | 
					public sealed partial record GetAuditLogMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] int Count
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
) : IMessageToController, ICanReply<ImmutableArray<AuditLogItem>>;
 | 
						[property: MemoryPackOrder(1)] int Count
 | 
				
			||||||
 | 
					) : IMessageToController, ICanReply<Result<ImmutableArray<AuditLogItem>, UserActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,5 +9,5 @@ namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			|||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record GetAuthenticatedUser(
 | 
					public sealed partial record GetAuthenticatedUser(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid UserGuid,
 | 
						[property: MemoryPackOrder(0)] Guid UserGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] ImmutableArray<byte> SessionToken
 | 
						[property: MemoryPackOrder(1)] ImmutableArray<byte> AuthToken
 | 
				
			||||||
) : IMessageToController, ICanReply<Optional<AuthenticatedUserInfo>>;
 | 
					) : IMessageToController, ICanReply<Optional<AuthenticatedUserInfo>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,14 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
using MemoryPack;
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.EventLog;
 | 
					using Phantom.Common.Data.Web.EventLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController;
 | 
					namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record GetEventLogMessage(
 | 
					public sealed partial record GetEventLogMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] int Count
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
) : IMessageToController, ICanReply<ImmutableArray<EventLogItem>>;
 | 
						[property: MemoryPackOrder(1)] int Count
 | 
				
			||||||
 | 
					) : IMessageToController, ICanReply<Result<ImmutableArray<EventLogItem>, UserActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,15 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController; 
 | 
					namespace Phantom.Common.Messages.Web.ToController; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record LaunchInstanceMessage(
 | 
					public sealed partial record LaunchInstanceMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
						[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] Guid InstanceGuid
 | 
						[property: MemoryPackOrder(2)] Guid InstanceGuid
 | 
				
			||||||
) : IMessageToController, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
 | 
					) : IMessageToController, ICanReply<Result<LaunchInstanceResult, UserInstanceActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using MemoryPack;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -8,4 +9,4 @@ namespace Phantom.Common.Messages.Web.ToController;
 | 
				
			|||||||
public sealed partial record LogInMessage(
 | 
					public sealed partial record LogInMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] string Username,
 | 
						[property: MemoryPackOrder(0)] string Username,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] string Password
 | 
						[property: MemoryPackOrder(1)] string Password
 | 
				
			||||||
) : IMessageToController, ICanReply<LogInSuccess?>;
 | 
					) : IMessageToController, ICanReply<Optional<LogInSuccess>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,16 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController; 
 | 
					namespace Phantom.Common.Messages.Web.ToController; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record SendCommandToInstanceMessage(
 | 
					public sealed partial record SendCommandToInstanceMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
						[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] Guid InstanceGuid,
 | 
						[property: MemoryPackOrder(2)] Guid InstanceGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(3)] string Command
 | 
						[property: MemoryPackOrder(3)] string Command
 | 
				
			||||||
) : IMessageToController, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
 | 
					) : IMessageToController, ICanReply<Result<SendCommandToInstanceResult, UserInstanceActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,17 @@
 | 
				
			|||||||
using MemoryPack;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using MemoryPack;
 | 
				
			||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Minecraft;
 | 
					using Phantom.Common.Data.Minecraft;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Common.Messages.Web.ToController; 
 | 
					namespace Phantom.Common.Messages.Web.ToController; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[MemoryPackable(GenerateType.VersionTolerant)]
 | 
					[MemoryPackable(GenerateType.VersionTolerant)]
 | 
				
			||||||
public sealed partial record StopInstanceMessage(
 | 
					public sealed partial record StopInstanceMessage(
 | 
				
			||||||
	[property: MemoryPackOrder(0)] Guid LoggedInUserGuid,
 | 
						[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
 | 
				
			||||||
	[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
						[property: MemoryPackOrder(1)] Guid AgentGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(2)] Guid InstanceGuid,
 | 
						[property: MemoryPackOrder(2)] Guid InstanceGuid,
 | 
				
			||||||
	[property: MemoryPackOrder(3)] MinecraftStopStrategy StopStrategy
 | 
						[property: MemoryPackOrder(3)] MinecraftStopStrategy StopStrategy
 | 
				
			||||||
) : IMessageToController, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
 | 
					) : IMessageToController, ICanReply<Result<StopInstanceResult, UserInstanceActionFailure>>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
@@ -24,30 +24,31 @@ public static class WebMessageRegistries {
 | 
				
			|||||||
	static WebMessageRegistries() {
 | 
						static WebMessageRegistries() {
 | 
				
			||||||
		ToController.Add<RegisterWebMessage>(0);
 | 
							ToController.Add<RegisterWebMessage>(0);
 | 
				
			||||||
		ToController.Add<UnregisterWebMessage>(1);
 | 
							ToController.Add<UnregisterWebMessage>(1);
 | 
				
			||||||
		ToController.Add<LogInMessage, LogInSuccess?>(2);
 | 
							ToController.Add<LogInMessage, Optional<LogInSuccess>>(2);
 | 
				
			||||||
		ToController.Add<LogOutMessage>(3);
 | 
							ToController.Add<LogOutMessage>(3);
 | 
				
			||||||
		ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(4);
 | 
							ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(4);
 | 
				
			||||||
		ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(5);
 | 
							ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(5);
 | 
				
			||||||
		ToController.Add<CreateUserMessage, CreateUserResult>(6);
 | 
							ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(6);
 | 
				
			||||||
		ToController.Add<DeleteUserMessage, DeleteUserResult>(7);
 | 
							ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(7);
 | 
				
			||||||
		ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(8);
 | 
							ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(8);
 | 
				
			||||||
		ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(9);
 | 
							ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(9);
 | 
				
			||||||
		ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(10);
 | 
							ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(10);
 | 
				
			||||||
		ToController.Add<ChangeUserRolesMessage, ChangeUserRolesResult>(11);
 | 
							ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(11);
 | 
				
			||||||
		ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(12);
 | 
							ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(12);
 | 
				
			||||||
		ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(13);
 | 
							ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(13);
 | 
				
			||||||
		ToController.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(14);
 | 
							ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(14);
 | 
				
			||||||
		ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(15);
 | 
							ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(15);
 | 
				
			||||||
		ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(16);
 | 
							ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(16);
 | 
				
			||||||
		ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(17);
 | 
							ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(17);
 | 
				
			||||||
		ToController.Add<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(18);
 | 
							ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(18);
 | 
				
			||||||
		ToController.Add<GetEventLogMessage, ImmutableArray<EventLogItem>>(19);
 | 
							ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(19);
 | 
				
			||||||
		ToController.Add<ReplyMessage>(127);
 | 
							ToController.Add<ReplyMessage>(127);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		ToWeb.Add<RegisterWebResultMessage>(0);
 | 
							ToWeb.Add<RegisterWebResultMessage>(0);
 | 
				
			||||||
		ToWeb.Add<RefreshAgentsMessage>(1);
 | 
							ToWeb.Add<RefreshAgentsMessage>(1);
 | 
				
			||||||
		ToWeb.Add<RefreshInstancesMessage>(2);
 | 
							ToWeb.Add<RefreshInstancesMessage>(2);
 | 
				
			||||||
		ToWeb.Add<InstanceOutputMessage>(3);
 | 
							ToWeb.Add<InstanceOutputMessage>(3);
 | 
				
			||||||
 | 
							ToWeb.Add<RefreshUserSessionMessage>(4);
 | 
				
			||||||
		ToWeb.Add<ReplyMessage>(127);
 | 
							ToWeb.Add<ReplyMessage>(127);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										353
									
								
								Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								Controller/Phantom.Controller.Database.Postgres/Migrations/20240407211636_UserAgentAccess.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,353 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Database.Postgres.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(ApplicationDbContext))]
 | 
				
			||||||
 | 
					    [Migration("20240407211636_UserAgentAccess")]
 | 
				
			||||||
 | 
					    partial class UserAgentAccess
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "8.0.0")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AgentGuid")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("BuildVersion")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("MaxInstances")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ushort>("MaxMemory")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("ProtocolVersion")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("AgentGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("Agents", "agents");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<long>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<JsonDocument>("Data")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("EventType")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("SubjectId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("SubjectType")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("UserGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<DateTime>("UtcTime")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("UserGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("AuditLog", "system");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("EventGuid")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("AgentGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<JsonDocument>("Data")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("EventType")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("SubjectId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("SubjectType")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<DateTime>("UtcTime")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("EventGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("EventLog", "system");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("InstanceGuid")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AgentGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("InstanceName")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("JavaRuntimeGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("JvmArguments")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("LaunchAutomatically")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ushort>("MemoryAllocation")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("MinecraftServerKind")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("MinecraftVersion")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("RconPort")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("ServerPort")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("InstanceGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("Instances", "agents");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<string>("Id")
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("Permissions", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("RoleGuid")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("RoleGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("Roles", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("RoleGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("PermissionId")
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("RoleGuid", "PermissionId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("PermissionId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("RolePermissions", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AgentGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("UserGuid", "AgentGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AgentGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("UserAgentAccess", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("PasswordHash")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("UserGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("Name")
 | 
				
			||||||
 | 
					                        .IsUnique();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("Users", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("PermissionId")
 | 
				
			||||||
 | 
					                        .HasColumnType("text");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("UserGuid", "PermissionId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("PermissionId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("UserPermissions", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("RoleGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("UserGuid", "RoleGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("RoleGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("UserRoles", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("UserGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.SetNull);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("User");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("PermissionId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("RoleGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("AgentGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("UserGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("PermissionId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("UserGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("RoleGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("UserGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Role");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("User");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,56 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Database.Postgres.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class UserAgentAccess : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "UserAgentAccess",
 | 
				
			||||||
 | 
					                schema: "identity",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    UserGuid = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    AgentGuid = table.Column<Guid>(type: "uuid", nullable: false)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("PK_UserAgentAccess", x => new { x.UserGuid, x.AgentGuid });
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "FK_UserAgentAccess_Agents_AgentGuid",
 | 
				
			||||||
 | 
					                        column: x => x.AgentGuid,
 | 
				
			||||||
 | 
					                        principalSchema: "agents",
 | 
				
			||||||
 | 
					                        principalTable: "Agents",
 | 
				
			||||||
 | 
					                        principalColumn: "AgentGuid",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "FK_UserAgentAccess_Users_UserGuid",
 | 
				
			||||||
 | 
					                        column: x => x.UserGuid,
 | 
				
			||||||
 | 
					                        principalSchema: "identity",
 | 
				
			||||||
 | 
					                        principalTable: "Users",
 | 
				
			||||||
 | 
					                        principalColumn: "UserGuid",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "IX_UserAgentAccess_AgentGuid",
 | 
				
			||||||
 | 
					                schema: "identity",
 | 
				
			||||||
 | 
					                table: "UserAgentAccess",
 | 
				
			||||||
 | 
					                column: "AgentGuid");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "UserAgentAccess",
 | 
				
			||||||
 | 
					                schema: "identity");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
#pragma warning disable 612, 618
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
            modelBuilder
 | 
					            modelBuilder
 | 
				
			||||||
                .HasAnnotation("ProductVersion", "7.0.11")
 | 
					                .HasAnnotation("ProductVersion", "8.0.0")
 | 
				
			||||||
                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
@@ -204,6 +204,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations
 | 
				
			|||||||
                    b.ToTable("RolePermissions", "identity");
 | 
					                    b.ToTable("RolePermissions", "identity");
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AgentGuid")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("UserGuid", "AgentGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AgentGuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("UserAgentAccess", "identity");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.Property<Guid>("UserGuid")
 | 
					                    b.Property<Guid>("UserGuid")
 | 
				
			||||||
@@ -281,6 +296,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations
 | 
				
			|||||||
                        .IsRequired();
 | 
					                        .IsRequired();
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("AgentGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("UserGuid")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired();
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
 | 
					            modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
 | 
					                    b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,6 +20,7 @@ public class ApplicationDbContext : DbContext {
 | 
				
			|||||||
	public DbSet<UserRoleEntity> UserRoles { get; init; } = null!;
 | 
						public DbSet<UserRoleEntity> UserRoles { get; init; } = null!;
 | 
				
			||||||
	public DbSet<UserPermissionEntity> UserPermissions { get; init; } = null!;
 | 
						public DbSet<UserPermissionEntity> UserPermissions { get; init; } = null!;
 | 
				
			||||||
	public DbSet<RolePermissionEntity> RolePermissions { get; init; } = null!;
 | 
						public DbSet<RolePermissionEntity> RolePermissions { get; init; } = null!;
 | 
				
			||||||
 | 
						public DbSet<UserAgentAccessEntity> UserAgentAccess { get; init; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public DbSet<AgentEntity> Agents { get; init; } = null!;
 | 
						public DbSet<AgentEntity> Agents { get; init; } = null!;
 | 
				
			||||||
	public DbSet<InstanceEntity> Instances { get; init; } = null!;
 | 
						public DbSet<InstanceEntity> Instances { get; init; } = null!;
 | 
				
			||||||
@@ -62,6 +63,12 @@ public class ApplicationDbContext : DbContext {
 | 
				
			|||||||
			b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
								b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
				
			||||||
			b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
								b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							builder.Entity<UserAgentAccessEntity>(static b => {
 | 
				
			||||||
 | 
								b.HasKey(static e => new { UserId = e.UserGuid, AgentId = e.AgentGuid });
 | 
				
			||||||
 | 
								b.HasOne<UserEntity>().WithMany().HasForeignKey(static e => e.UserGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
				
			||||||
 | 
								b.HasOne<AgentEntity>().WithMany().HasForeignKey(static e => e.AgentGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected override void ConfigureConventions(ModelConfigurationBuilder builder) {
 | 
						protected override void ConfigureConventions(ModelConfigurationBuilder builder) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Database.Entities;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[Table("UserAgentAccess", Schema = "identity")]
 | 
				
			||||||
 | 
					public sealed class UserAgentAccessEntity {
 | 
				
			||||||
 | 
						public Guid UserGuid { get; init; }
 | 
				
			||||||
 | 
						public Guid AgentGuid { get; init; }
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public UserAgentAccessEntity(Guid userGuid, Guid agentGuid) {
 | 
				
			||||||
 | 
							UserGuid = userGuid;
 | 
				
			||||||
 | 
							AgentGuid = agentGuid;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -17,11 +17,12 @@ public sealed class EventLogRepository {
 | 
				
			|||||||
		db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
 | 
							db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
 | 
						public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(ImmutableHashSet<Guid> agentGuids, int count, CancellationToken cancellationToken) {
 | 
				
			||||||
		return db.Ctx
 | 
							return db.Ctx
 | 
				
			||||||
		         .EventLog
 | 
							         .EventLog
 | 
				
			||||||
		         .AsQueryable()
 | 
							         .AsQueryable()
 | 
				
			||||||
		         .OrderByDescending(static entity => entity.UtcTime)
 | 
							         .OrderByDescending(static entity => entity.UtcTime)
 | 
				
			||||||
 | 
							         .Where(entity => entity.AgentGuid == null || agentGuids.Contains(entity.AgentGuid.Value))
 | 
				
			||||||
		         .Take(count)
 | 
							         .Take(count)
 | 
				
			||||||
		         .AsAsyncEnumerable()
 | 
							         .AsAsyncEnumerable()
 | 
				
			||||||
		         .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString()))
 | 
							         .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString()))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database.Entities;
 | 
				
			||||||
 | 
					using Phantom.Utils.Collections;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed class PermissionRepository {
 | 
				
			||||||
 | 
						private readonly ILazyDbContext db;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public PermissionRepository(ILazyDbContext db) {
 | 
				
			||||||
 | 
							this.db = db;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public async Task<PermissionSet> GetAllUserPermissions(UserEntity user) {
 | 
				
			||||||
 | 
							var userPermissions = db.Ctx.UserPermissions
 | 
				
			||||||
 | 
							                        .Where(up => up.UserGuid == user.UserGuid)
 | 
				
			||||||
 | 
							                        .Select(static up => up.PermissionId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var rolePermissions = db.Ctx.UserRoles
 | 
				
			||||||
 | 
							                        .Where(ur => ur.UserGuid == user.UserGuid)
 | 
				
			||||||
 | 
							                        .Join(db.Ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Task<ImmutableHashSet<Guid>> GetManagedAgentGuids(UserEntity user) {
 | 
				
			||||||
 | 
							return db.Ctx.UserAgentAccess
 | 
				
			||||||
 | 
							         .Where(ua => ua.UserGuid == user.UserGuid)
 | 
				
			||||||
 | 
							         .Select(static ua => ua.AgentGuid)
 | 
				
			||||||
 | 
							         .AsAsyncEnumerable()
 | 
				
			||||||
 | 
							         .ToImmutableSetAsync();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -96,6 +96,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
		Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
 | 
							Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
 | 
				
			||||||
		ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
 | 
							ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
 | 
				
			||||||
		Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
 | 
							Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
 | 
				
			||||||
 | 
							Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts);
 | 
				
			||||||
		ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
 | 
							ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
 | 
							ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
 | 
							ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
 | 
				
			||||||
@@ -146,20 +147,20 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<TReply, InstanceActionFailure>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
 | 
						private async Task<Result<TReply, InstanceActionFailure>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
 | 
				
			||||||
		if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) {
 | 
							if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) {
 | 
				
			||||||
			return instance.Request(command, cancellationToken);
 | 
								return await instance.Request(command, cancellationToken);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		else {
 | 
							else {
 | 
				
			||||||
			Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid);
 | 
								Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid);
 | 
				
			||||||
			return Task.FromResult<Result<TReply, InstanceActionFailure>>(InstanceActionFailure.InstanceDoesNotExist);
 | 
								return InstanceActionFailure.InstanceDoesNotExist;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
 | 
						private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
 | 
				
			||||||
		var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
 | 
							var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
 | 
							foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
 | 
				
			||||||
			var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
 | 
								var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
 | 
				
			||||||
			configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
 | 
								configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -183,15 +184,17 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
	
 | 
						
 | 
				
			||||||
	public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand;
 | 
						public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public sealed record CreateOrUpdateInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
 | 
						public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public sealed record LaunchInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record UpdateInstancePlayerCountsCommand(Guid InstanceGuid, InstancePlayerCounts? PlayerCounts) : ICommand;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public sealed record StopInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record StopInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public sealed record SendCommandToInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead;
 | 
						public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -308,7 +311,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
			instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration));
 | 
								instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.AuditLogUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance);
 | 
							var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.LoggedInUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		return instanceActorRef.Request(configureInstanceCommand, cancellationToken)
 | 
							return instanceActorRef.Request(configureInstanceCommand, cancellationToken)
 | 
				
			||||||
		                       .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand);
 | 
							                       .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand);
 | 
				
			||||||
@@ -330,7 +333,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
		else {
 | 
							else {
 | 
				
			||||||
			string action = isCreating ? "adding" : "editing";
 | 
								string action = isCreating ? "adding" : "editing";
 | 
				
			||||||
			string relation = isCreating ? "to agent" : "in agent";
 | 
								string relation = isCreating ? "to agent" : "in agent";
 | 
				
			||||||
			string reason = result.Map(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
 | 
								string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
 | 
								Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
@@ -342,16 +345,20 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
 | 
				
			|||||||
		TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
 | 
							TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						private void UpdateInstancePlayerCounts(UpdateInstancePlayerCountsCommand command) {
 | 
				
			||||||
 | 
							TellInstance(command.InstanceGuid, new InstanceActor.SetPlayerCountsCommand(command.PlayerCounts));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
 | 
						private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
 | 
				
			||||||
		return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.AuditLogUserGuid));
 | 
							return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(StopInstanceCommand command) {
 | 
						private Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(StopInstanceCommand command) {
 | 
				
			||||||
		return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.AuditLogUserGuid, command.StopStrategy));
 | 
							return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.LoggedInUserGuid, command.StopStrategy));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
 | 
						private Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
 | 
				
			||||||
		return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.AuditLogUserGuid, command.Command));
 | 
							return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.LoggedInUserGuid, command.Command));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
 | 
						private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,16 @@
 | 
				
			|||||||
using System.Collections.Concurrent;
 | 
					using System.Collections.Concurrent;
 | 
				
			||||||
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
using Akka.Actor;
 | 
					using Akka.Actor;
 | 
				
			||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Agent;
 | 
					using Phantom.Common.Data.Agent;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
using Phantom.Common.Data.Web.Agent;
 | 
					using Phantom.Common.Data.Web.Agent;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Agent;
 | 
					using Phantom.Common.Messages.Agent;
 | 
				
			||||||
using Phantom.Common.Messages.Agent.ToAgent;
 | 
					using Phantom.Common.Messages.Agent.ToAgent;
 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
using Phantom.Controller.Minecraft;
 | 
					using Phantom.Controller.Minecraft;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
@@ -22,17 +25,19 @@ sealed class AgentManager {
 | 
				
			|||||||
	private readonly AuthToken authToken;
 | 
						private readonly AuthToken authToken;
 | 
				
			||||||
	private readonly ControllerState controllerState;
 | 
						private readonly ControllerState controllerState;
 | 
				
			||||||
	private readonly MinecraftVersions minecraftVersions;
 | 
						private readonly MinecraftVersions minecraftVersions;
 | 
				
			||||||
 | 
						private readonly UserLoginManager userLoginManager;
 | 
				
			||||||
	private readonly IDbContextProvider dbProvider;
 | 
						private readonly IDbContextProvider dbProvider;
 | 
				
			||||||
	private readonly CancellationToken cancellationToken;
 | 
						private readonly CancellationToken cancellationToken;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByGuid = new ();
 | 
						private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByGuid = new ();
 | 
				
			||||||
	private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
 | 
						private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public AgentManager(IActorRefFactory actorSystem, AuthToken authToken, ControllerState controllerState, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
 | 
						public AgentManager(IActorRefFactory actorSystem, AuthToken authToken, ControllerState controllerState, MinecraftVersions minecraftVersions, UserLoginManager userLoginManager, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
 | 
				
			||||||
		this.actorSystem = actorSystem;
 | 
							this.actorSystem = actorSystem;
 | 
				
			||||||
		this.authToken = authToken;
 | 
							this.authToken = authToken;
 | 
				
			||||||
		this.controllerState = controllerState;
 | 
							this.controllerState = controllerState;
 | 
				
			||||||
		this.minecraftVersions = minecraftVersions;
 | 
							this.minecraftVersions = minecraftVersions;
 | 
				
			||||||
 | 
							this.userLoginManager = userLoginManager;
 | 
				
			||||||
		this.dbProvider = dbProvider;
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
		this.cancellationToken = cancellationToken;
 | 
							this.cancellationToken = cancellationToken;
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
@@ -83,7 +88,18 @@ sealed class AgentManager {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<Result<TReply, InstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Guid agentGuid, TCommand command) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
 | 
						public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Permission requiredPermission, ImmutableArray<byte> authToken, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
 | 
				
			||||||
		return agentsByGuid.TryGetValue(agentGuid, out var agent) ? await agent.Request(command, cancellationToken) : InstanceActionFailure.AgentDoesNotExist;
 | 
							var loggedInUser = userLoginManager.GetLoggedInUser(authToken);
 | 
				
			||||||
 | 
							if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (!agentsByGuid.TryGetValue(agentGuid, out var agent)) {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) InstanceActionFailure.AgentDoesNotExist;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var command = commandFactoryFromLoggedInUserGuid(loggedInUser.Guid!.Value);
 | 
				
			||||||
 | 
							var result = await agent.Request(command, cancellationToken);
 | 
				
			||||||
 | 
							return result.MapError(static error => (UserInstanceActionFailure) error);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ using Phantom.Controller.Services.Events;
 | 
				
			|||||||
using Phantom.Controller.Services.Instances;
 | 
					using Phantom.Controller.Services.Instances;
 | 
				
			||||||
using Phantom.Controller.Services.Rpc;
 | 
					using Phantom.Controller.Services.Rpc;
 | 
				
			||||||
using Phantom.Controller.Services.Users;
 | 
					using Phantom.Controller.Services.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
using IMessageFromAgentToController = Phantom.Common.Messages.Agent.IMessageToController;
 | 
					using IMessageFromAgentToController = Phantom.Common.Messages.Agent.IMessageToController;
 | 
				
			||||||
@@ -24,17 +25,18 @@ public sealed class ControllerServices : IDisposable {
 | 
				
			|||||||
	private ControllerState ControllerState { get; }
 | 
						private ControllerState ControllerState { get; }
 | 
				
			||||||
	private MinecraftVersions MinecraftVersions { get; }
 | 
						private MinecraftVersions MinecraftVersions { get; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private AgentManager AgentManager { get; }
 | 
						private AuthenticatedUserCache AuthenticatedUserCache { get; }
 | 
				
			||||||
	private InstanceLogManager InstanceLogManager { get; }
 | 
					 | 
				
			||||||
	private EventLogManager EventLogManager { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	private UserManager UserManager { get; }
 | 
						private UserManager UserManager { get; }
 | 
				
			||||||
	private RoleManager RoleManager { get; }
 | 
						private RoleManager RoleManager { get; }
 | 
				
			||||||
	private PermissionManager PermissionManager { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	private UserRoleManager UserRoleManager { get; }
 | 
						private UserRoleManager UserRoleManager { get; }
 | 
				
			||||||
	private UserLoginManager UserLoginManager { get; }
 | 
						private UserLoginManager UserLoginManager { get; }
 | 
				
			||||||
 | 
						private PermissionManager PermissionManager { get; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private AgentManager AgentManager { get; }
 | 
				
			||||||
 | 
						private InstanceLogManager InstanceLogManager { get; }
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	private AuditLogManager AuditLogManager { get; }
 | 
						private AuditLogManager AuditLogManager { get; }
 | 
				
			||||||
 | 
						private EventLogManager EventLogManager { get; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public IRegistrationHandler<IMessageToAgent, IMessageFromAgentToController, RegisterAgentMessage> AgentRegistrationHandler { get; }
 | 
						public IRegistrationHandler<IMessageToAgent, IMessageFromAgentToController, RegisterAgentMessage> AgentRegistrationHandler { get; }
 | 
				
			||||||
	public IRegistrationHandler<IMessageToWeb, IMessageFromWebToController, RegisterWebMessage> WebRegistrationHandler { get; }
 | 
						public IRegistrationHandler<IMessageToWeb, IMessageFromWebToController, RegisterWebMessage> WebRegistrationHandler { get; }
 | 
				
			||||||
@@ -51,17 +53,18 @@ public sealed class ControllerServices : IDisposable {
 | 
				
			|||||||
		this.ControllerState = new ControllerState();
 | 
							this.ControllerState = new ControllerState();
 | 
				
			||||||
		this.MinecraftVersions = new MinecraftVersions();
 | 
							this.MinecraftVersions = new MinecraftVersions();
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		this.AgentManager = new AgentManager(ActorSystem, agentAuthToken, ControllerState, MinecraftVersions, dbProvider, cancellationToken);
 | 
							this.AuthenticatedUserCache = new AuthenticatedUserCache();
 | 
				
			||||||
		this.InstanceLogManager = new InstanceLogManager();
 | 
							this.UserManager = new UserManager(AuthenticatedUserCache, ControllerState, dbProvider);
 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		this.UserManager = new UserManager(dbProvider);
 | 
					 | 
				
			||||||
		this.RoleManager = new RoleManager(dbProvider);
 | 
							this.RoleManager = new RoleManager(dbProvider);
 | 
				
			||||||
 | 
							this.UserRoleManager = new UserRoleManager(AuthenticatedUserCache, ControllerState, dbProvider);
 | 
				
			||||||
 | 
							this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, UserManager, dbProvider);
 | 
				
			||||||
		this.PermissionManager = new PermissionManager(dbProvider);
 | 
							this.PermissionManager = new PermissionManager(dbProvider);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		this.UserRoleManager = new UserRoleManager(dbProvider);
 | 
							this.AgentManager = new AgentManager(ActorSystem, agentAuthToken, ControllerState, MinecraftVersions, UserLoginManager, dbProvider, cancellationToken);
 | 
				
			||||||
		this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager, dbProvider);
 | 
							this.InstanceLogManager = new InstanceLogManager();
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		this.AuditLogManager = new AuditLogManager(dbProvider);
 | 
							this.AuditLogManager = new AuditLogManager(dbProvider);
 | 
				
			||||||
		this.EventLogManager = new EventLogManager(ActorSystem, dbProvider, shutdownCancellationToken);
 | 
							this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		this.AgentRegistrationHandler = new AgentRegistrationHandler(AgentManager, InstanceLogManager, EventLogManager);
 | 
							this.AgentRegistrationHandler = new AgentRegistrationHandler(AgentManager, InstanceLogManager, EventLogManager);
 | 
				
			||||||
		this.WebRegistrationHandler = new WebRegistrationHandler(webAuthToken, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);
 | 
							this.WebRegistrationHandler = new WebRegistrationHandler(webAuthToken, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,6 +19,8 @@ sealed class ControllerState {
 | 
				
			|||||||
	public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide;
 | 
						public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide;
 | 
				
			||||||
	public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide;
 | 
						public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						public event EventHandler<Guid>? UserUpdatedOrDeleted;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public void UpdateAgent(Agent agent) {
 | 
						public void UpdateAgent(Agent agent) {
 | 
				
			||||||
		agentsByGuid.PublisherSide.Publish(static (agentsByGuid, agent) => agentsByGuid.SetItem(agent.AgentGuid, 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) {
 | 
						public void UpdateInstance(Instance instance) {
 | 
				
			||||||
		instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
 | 
							instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public void UpdateOrDeleteUser(Guid userGuid) {
 | 
				
			||||||
 | 
							UserUpdatedOrDeleted?.Invoke(null, userGuid);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,18 +1,23 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
using Akka.Actor;
 | 
					using Akka.Actor;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.EventLog;
 | 
					using Phantom.Common.Data.Web.EventLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
using Phantom.Controller.Database.Repositories;
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Controller.Services.Events; 
 | 
					namespace Phantom.Controller.Services.Events; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sealed partial class EventLogManager {
 | 
					sealed partial class EventLogManager {
 | 
				
			||||||
 | 
						private readonly ControllerState controllerState;
 | 
				
			||||||
	private readonly ActorRef<EventLogDatabaseStorageActor.ICommand> databaseStorageActor;
 | 
						private readonly ActorRef<EventLogDatabaseStorageActor.ICommand> databaseStorageActor;
 | 
				
			||||||
	private readonly IDbContextProvider dbProvider;
 | 
						private readonly IDbContextProvider dbProvider;
 | 
				
			||||||
	private readonly CancellationToken cancellationToken;
 | 
						private readonly CancellationToken cancellationToken;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public EventLogManager(IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
 | 
						public EventLogManager(ControllerState controllerState, IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
 | 
				
			||||||
 | 
							this.controllerState = controllerState;
 | 
				
			||||||
		this.databaseStorageActor = actorSystem.ActorOf(EventLogDatabaseStorageActor.Factory(new EventLogDatabaseStorageActor.Init(dbProvider, cancellationToken)), "EventLogDatabaseStorage");
 | 
							this.databaseStorageActor = actorSystem.ActorOf(EventLogDatabaseStorageActor.Factory(new EventLogDatabaseStorageActor.Init(dbProvider, cancellationToken)), "EventLogDatabaseStorage");
 | 
				
			||||||
		this.dbProvider = dbProvider;
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
		this.cancellationToken = cancellationToken;
 | 
							this.cancellationToken = cancellationToken;
 | 
				
			||||||
@@ -22,8 +27,14 @@ sealed partial class EventLogManager {
 | 
				
			|||||||
		databaseStorageActor.Tell(new EventLogDatabaseStorageActor.StoreEventCommand(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
 | 
							databaseStorageActor.Tell(new EventLogDatabaseStorageActor.StoreEventCommand(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public async Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count) {
 | 
						public async Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetMostRecentItems(LoggedInUser loggedInUser, int count) {
 | 
				
			||||||
 | 
							if (!loggedInUser.CheckPermission(Permission.ViewEvents)) {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var accessibleAgentGuids = loggedInUser.FilterAccessibleAgentGuids(controllerState.AgentsByGuid.Keys.ToImmutableHashSet());
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
		return await new EventLogRepository(db).GetMostRecentItems(count, cancellationToken);
 | 
							return await new EventLogRepository(db).GetMostRecentItems(accessibleAgentGuids, count, cancellationToken);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
 | 
				
			|||||||
	
 | 
						
 | 
				
			||||||
	private InstanceConfiguration configuration;
 | 
						private InstanceConfiguration configuration;
 | 
				
			||||||
	private IInstanceStatus status;
 | 
						private IInstanceStatus status;
 | 
				
			||||||
 | 
						private InstancePlayerCounts? playerCounts;
 | 
				
			||||||
	private bool launchAutomatically;
 | 
						private bool launchAutomatically;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
 | 
						private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
 | 
				
			||||||
@@ -35,11 +36,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
 | 
				
			|||||||
		this.agentConnection = init.AgentConnection;
 | 
							this.agentConnection = init.AgentConnection;
 | 
				
			||||||
		this.cancellationToken = init.CancellationToken;
 | 
							this.cancellationToken = init.CancellationToken;
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		(this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance;
 | 
							(this.instanceGuid, this.configuration, this.status, this.playerCounts, this.launchAutomatically) = init.Instance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
 | 
							this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		Receive<SetStatusCommand>(SetStatus);
 | 
							Receive<SetStatusCommand>(SetStatus);
 | 
				
			||||||
 | 
							Receive<SetPlayerCountsCommand>(SetPlayerCounts);
 | 
				
			||||||
		ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
 | 
							ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
 | 
				
			||||||
		ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
 | 
							ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
 | 
				
			||||||
		ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
 | 
							ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
 | 
				
			||||||
@@ -47,7 +49,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void NotifyInstanceUpdated() {
 | 
						private void NotifyInstanceUpdated() {
 | 
				
			||||||
		agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically)));
 | 
							agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, playerCounts, launchAutomatically)));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void SetLaunchAutomatically(bool newValue) {
 | 
						private void SetLaunchAutomatically(bool newValue) {
 | 
				
			||||||
@@ -66,6 +68,8 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
 | 
						public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
 | 
						public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
 | 
				
			||||||
@@ -76,6 +80,16 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	private void SetStatus(SetStatusCommand command) {
 | 
						private void SetStatus(SetStatusCommand command) {
 | 
				
			||||||
		status = command.Status;
 | 
							status = command.Status;
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (!status.IsRunning() && status != InstanceStatus.Offline /* Guard against temporary disconnects */) {
 | 
				
			||||||
 | 
								playerCounts = null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							NotifyInstanceUpdated();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private void SetPlayerCounts(SetPlayerCountsCommand command) {
 | 
				
			||||||
 | 
							playerCounts = command.PlayerCounts;
 | 
				
			||||||
		NotifyInstanceUpdated();
 | 
							NotifyInstanceUpdated();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
		Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
 | 
							Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
 | 
				
			||||||
		Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
 | 
							Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
 | 
				
			||||||
		Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
 | 
							Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
 | 
				
			||||||
 | 
							Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts);
 | 
				
			||||||
		Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
 | 
							Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
 | 
				
			||||||
		Receive<InstanceOutputMessage>(HandleInstanceOutput);
 | 
							Receive<InstanceOutputMessage>(HandleInstanceOutput);
 | 
				
			||||||
		Receive<ReplyMessage>(HandleReply);
 | 
							Receive<ReplyMessage>(HandleReply);
 | 
				
			||||||
@@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
		agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
 | 
							agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
 | 
				
			||||||
 | 
							agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
 | 
						private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
 | 
				
			||||||
		message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
 | 
							message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,22 +30,31 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
 | 
				
			|||||||
		ReceiveAsync<RefreshAgentsCommand>(RefreshAgents);
 | 
							ReceiveAsync<RefreshAgentsCommand>(RefreshAgents);
 | 
				
			||||||
		ReceiveAsync<RefreshInstancesCommand>(RefreshInstances);
 | 
							ReceiveAsync<RefreshInstancesCommand>(RefreshInstances);
 | 
				
			||||||
		ReceiveAsync<ReceiveInstanceLogsCommand>(ReceiveInstanceLogs);
 | 
							ReceiveAsync<ReceiveInstanceLogsCommand>(ReceiveInstanceLogs);
 | 
				
			||||||
 | 
							ReceiveAsync<RefreshUserSessionCommand>(RefreshUserSession);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected override void PreStart() {
 | 
						protected override void PreStart() {
 | 
				
			||||||
		controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state));
 | 
							controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state));
 | 
				
			||||||
		controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state));
 | 
							controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state));
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 | 
							controllerState.UserUpdatedOrDeleted += OnUserUpdatedOrDeleted;
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		instanceLogManager.LogsReceived += OnInstanceLogsReceived;
 | 
							instanceLogManager.LogsReceived += OnInstanceLogsReceived;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected override void PostStop() {
 | 
						protected override void PostStop() {
 | 
				
			||||||
		instanceLogManager.LogsReceived -= OnInstanceLogsReceived;
 | 
							instanceLogManager.LogsReceived -= OnInstanceLogsReceived;
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 | 
							controllerState.UserUpdatedOrDeleted -= OnUserUpdatedOrDeleted;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		controllerState.AgentsByGuidReceiver.Unregister(SelfTyped);
 | 
							controllerState.AgentsByGuidReceiver.Unregister(SelfTyped);
 | 
				
			||||||
		controllerState.InstancesByGuidReceiver.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) {
 | 
						private void OnInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
 | 
				
			||||||
		selfCached.Tell(new ReceiveInstanceLogsCommand(e.InstanceGuid, e.Lines));
 | 
							selfCached.Tell(new ReceiveInstanceLogsCommand(e.InstanceGuid, e.Lines));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -58,6 +67,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
 | 
				
			|||||||
	
 | 
						
 | 
				
			||||||
	private sealed record ReceiveInstanceLogsCommand(Guid InstanceGuid, ImmutableArray<string> Lines) : ICommand;
 | 
						private sealed record ReceiveInstanceLogsCommand(Guid InstanceGuid, ImmutableArray<string> Lines) : ICommand;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						private sealed record RefreshUserSessionCommand(Guid UserGuid) : ICommand;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task RefreshAgents(RefreshAgentsCommand command) {
 | 
						private Task RefreshAgents(RefreshAgentsCommand command) {
 | 
				
			||||||
		return connection.Send(new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray()));
 | 
							return connection.Send(new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray()));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -69,4 +80,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
 | 
				
			|||||||
	private Task ReceiveInstanceLogs(ReceiveInstanceLogsCommand command) {
 | 
						private Task ReceiveInstanceLogs(ReceiveInstanceLogsCommand command) {
 | 
				
			||||||
		return connection.Send(new InstanceOutputMessage(command.InstanceGuid, command.Lines));
 | 
							return connection.Send(new InstanceOutputMessage(command.InstanceGuid, command.Lines));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private Task RefreshUserSession(RefreshUserSessionCommand command) {
 | 
				
			||||||
 | 
							return connection.Send(new RefreshUserSessionMessage(command.UserGuid));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@ using Phantom.Controller.Services.Agents;
 | 
				
			|||||||
using Phantom.Controller.Services.Events;
 | 
					using Phantom.Controller.Services.Events;
 | 
				
			||||||
using Phantom.Controller.Services.Instances;
 | 
					using Phantom.Controller.Services.Instances;
 | 
				
			||||||
using Phantom.Controller.Services.Users;
 | 
					using Phantom.Controller.Services.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -70,24 +71,24 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		ReceiveAsync<RegisterWebMessage>(HandleRegisterWeb);
 | 
							ReceiveAsync<RegisterWebMessage>(HandleRegisterWeb);
 | 
				
			||||||
		Receive<UnregisterWebMessage>(HandleUnregisterWeb);
 | 
							Receive<UnregisterWebMessage>(HandleUnregisterWeb);
 | 
				
			||||||
		ReceiveAndReplyLater<LogInMessage, LogInSuccess?>(HandleLogIn);
 | 
							ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(HandleLogIn);
 | 
				
			||||||
		Receive<LogOutMessage>(HandleLogOut);
 | 
							Receive<LogOutMessage>(HandleLogOut);
 | 
				
			||||||
		ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser);
 | 
							ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser);
 | 
				
			||||||
		ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser);
 | 
							ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser);
 | 
				
			||||||
		ReceiveAndReplyLater<CreateUserMessage, CreateUserResult>(HandleCreateUser);
 | 
							ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(HandleCreateUser);
 | 
				
			||||||
		ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers);
 | 
							ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers);
 | 
				
			||||||
		ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles);
 | 
							ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles);
 | 
				
			||||||
		ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles);
 | 
							ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles);
 | 
				
			||||||
		ReceiveAndReplyLater<ChangeUserRolesMessage, ChangeUserRolesResult>(HandleChangeUserRoles);
 | 
							ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(HandleChangeUserRoles);
 | 
				
			||||||
		ReceiveAndReplyLater<DeleteUserMessage, DeleteUserResult>(HandleDeleteUser);
 | 
							ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(HandleDeleteUser);
 | 
				
			||||||
		ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(HandleCreateOrUpdateInstance);
 | 
							ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(HandleCreateOrUpdateInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance);
 | 
							ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(HandleLaunchInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance);
 | 
							ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(HandleStopInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance);
 | 
							ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(HandleSendCommandToInstance);
 | 
				
			||||||
		ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions);
 | 
							ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions);
 | 
				
			||||||
		ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes);
 | 
							ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes);
 | 
				
			||||||
		ReceiveAndReplyLater<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(HandleGetAuditLog);
 | 
							ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(HandleGetAuditLog);
 | 
				
			||||||
		ReceiveAndReplyLater<GetEventLogMessage, ImmutableArray<EventLogItem>>(HandleGetEventLog);
 | 
							ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(HandleGetEventLog);
 | 
				
			||||||
		Receive<ReplyMessage>(HandleReply);
 | 
							Receive<ReplyMessage>(HandleReply);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -99,7 +100,7 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
		connection.Close();
 | 
							connection.Close();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<LogInSuccess?> HandleLogIn(LogInMessage message) {
 | 
						private Task<Optional<LogInSuccess>> HandleLogIn(LogInMessage message) {
 | 
				
			||||||
		return userLoginManager.LogIn(message.Username, message.Password);
 | 
							return userLoginManager.LogIn(message.Username, message.Password);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -108,15 +109,15 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Optional<AuthenticatedUserInfo> GetAuthenticatedUser(GetAuthenticatedUser message) {
 | 
						private Optional<AuthenticatedUserInfo> GetAuthenticatedUser(GetAuthenticatedUser message) {
 | 
				
			||||||
		return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.SessionToken);
 | 
							return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
 | 
						private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
 | 
				
			||||||
		return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
 | 
							return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<CreateUserResult> HandleCreateUser(CreateUserMessage message) {
 | 
						private Task<Result<CreateUserResult, UserActionFailure>> HandleCreateUser(CreateUserMessage message) {
 | 
				
			||||||
		return userManager.Create(message.LoggedInUserGuid, message.Username, message.Password);
 | 
							return userManager.Create(userLoginManager.GetLoggedInUser(message.AuthToken), message.Username, message.Password);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) {
 | 
						private Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) {
 | 
				
			||||||
@@ -131,28 +132,48 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
		return userRoleManager.GetUserRoles(message.UserGuids);
 | 
							return userRoleManager.GetUserRoles(message.UserGuids);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message) {
 | 
						private Task<Result<ChangeUserRolesResult, UserActionFailure>> HandleChangeUserRoles(ChangeUserRolesMessage message) {
 | 
				
			||||||
		return userRoleManager.ChangeUserRoles(message.LoggedInUserGuid, message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
 | 
							return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message) {
 | 
						private Task<Result<DeleteUserResult, UserActionFailure>> HandleDeleteUser(DeleteUserMessage message) {
 | 
				
			||||||
		return userManager.DeleteByGuid(message.LoggedInUserGuid, message.SubjectUserGuid);
 | 
							return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
 | 
						private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
 | 
				
			||||||
		return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(message.Configuration.AgentGuid, new AgentActor.CreateOrUpdateInstanceCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Configuration));
 | 
							return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(
 | 
				
			||||||
 | 
								Permission.CreateInstances,
 | 
				
			||||||
 | 
								message.AuthToken,
 | 
				
			||||||
 | 
								message.Configuration.AgentGuid,
 | 
				
			||||||
 | 
								loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration)
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
 | 
						private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
 | 
				
			||||||
		return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(message.AgentGuid, new AgentActor.LaunchInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid));
 | 
							return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(
 | 
				
			||||||
 | 
								Permission.ControlInstances,
 | 
				
			||||||
 | 
								message.AuthToken,
 | 
				
			||||||
 | 
								message.AgentGuid,
 | 
				
			||||||
 | 
								loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid)
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<StopInstanceResult, InstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
 | 
						private Task<Result<StopInstanceResult, UserInstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
 | 
				
			||||||
		return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(message.AgentGuid, new AgentActor.StopInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.StopStrategy));
 | 
							return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(
 | 
				
			||||||
 | 
								Permission.ControlInstances,
 | 
				
			||||||
 | 
								message.AuthToken,
 | 
				
			||||||
 | 
								message.AgentGuid,
 | 
				
			||||||
 | 
								loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy)
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
 | 
						private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
 | 
				
			||||||
		return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(message.AgentGuid, new AgentActor.SendCommandToInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.Command));
 | 
							return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(
 | 
				
			||||||
 | 
								Permission.ControlInstances,
 | 
				
			||||||
 | 
								message.AuthToken,
 | 
				
			||||||
 | 
								message.AgentGuid,
 | 
				
			||||||
 | 
								loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command)
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) {
 | 
						private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) {
 | 
				
			||||||
@@ -163,12 +184,12 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
 | 
				
			|||||||
		return controllerState.AgentJavaRuntimesByGuid;
 | 
							return controllerState.AgentJavaRuntimesByGuid;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message) {
 | 
						private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> HandleGetAuditLog(GetAuditLogMessage message) {
 | 
				
			||||||
		return auditLogManager.GetMostRecentItems(message.Count);
 | 
							return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message) {
 | 
						private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> HandleGetEventLog(GetEventLogMessage message) {
 | 
				
			||||||
		return eventLogManager.GetMostRecentItems(message.Count);
 | 
							return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void HandleReply(ReplyMessage message) {
 | 
						private void HandleReply(ReplyMessage message) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,7 @@ using Phantom.Controller.Services.Agents;
 | 
				
			|||||||
using Phantom.Controller.Services.Events;
 | 
					using Phantom.Controller.Services.Events;
 | 
				
			||||||
using Phantom.Controller.Services.Instances;
 | 
					using Phantom.Controller.Services.Instances;
 | 
				
			||||||
using Phantom.Controller.Services.Users;
 | 
					using Phantom.Controller.Services.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,10 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.AuditLog;
 | 
					using Phantom.Common.Data.Web.AuditLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
using Phantom.Controller.Database.Repositories;
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Controller.Services.Users; 
 | 
					namespace Phantom.Controller.Services.Users; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -12,7 +15,11 @@ sealed class AuditLogManager {
 | 
				
			|||||||
		this.dbProvider = dbProvider;
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count) {
 | 
						public async Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetMostRecentItems(LoggedInUser loggedInUser, int count) {
 | 
				
			||||||
 | 
							if (!loggedInUser.CheckPermission(Permission.ViewAudit)) {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
		return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None);
 | 
							return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,34 +36,6 @@ sealed class PermissionManager {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<PermissionSet> FetchPermissionsForAllUsers(Guid userId) {
 | 
					 | 
				
			||||||
		await using var ctx = dbProvider.Eager();
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		var userPermissions = ctx.UserPermissions
 | 
					 | 
				
			||||||
		                         .Where(up => up.UserGuid == userId)
 | 
					 | 
				
			||||||
		                         .Select(static up => up.PermissionId);
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		var rolePermissions = ctx.UserRoles
 | 
					 | 
				
			||||||
		                         .Where(ur => ur.UserGuid == userId)
 | 
					 | 
				
			||||||
		                         .Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	
 | 
					 | 
				
			||||||
	public async Task<PermissionSet> FetchPermissionsForUserId(Guid userId) {
 | 
					 | 
				
			||||||
		await using var ctx = dbProvider.Eager();
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		var userPermissions = ctx.UserPermissions
 | 
					 | 
				
			||||||
		                         .Where(up => up.UserGuid == userId)
 | 
					 | 
				
			||||||
		                         .Select(static up => up.PermissionId);
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		var rolePermissions = ctx.UserRoles
 | 
					 | 
				
			||||||
		                         .Where(ur => ur.UserGuid == userId)
 | 
					 | 
				
			||||||
		                         .Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
 | 
						public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
 | 
				
			||||||
		return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
 | 
							return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					using System.Collections.Concurrent;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database.Entities;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sealed class AuthenticatedUserCache {
 | 
				
			||||||
 | 
						private readonly ConcurrentDictionary<Guid, AuthenticatedUserInfo> authenticatedUsersByGuid = new ();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public bool TryGet(Guid userGuid, out AuthenticatedUserInfo? userInfo) {
 | 
				
			||||||
 | 
							return authenticatedUsersByGuid.TryGetValue(userGuid, out userInfo);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public async Task<AuthenticatedUserInfo?> Update(UserEntity user, ILazyDbContext db) {
 | 
				
			||||||
 | 
							var permissionRepository = new PermissionRepository(db);
 | 
				
			||||||
 | 
							var userPermissions = await permissionRepository.GetAllUserPermissions(user);
 | 
				
			||||||
 | 
							var userManagedAgentGuids = await permissionRepository.GetManagedAgentGuids(user);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var userGuid = user.UserGuid;
 | 
				
			||||||
 | 
							var userInfo = new AuthenticatedUserInfo(userGuid, user.Name, userPermissions, userManagedAgentGuids);
 | 
				
			||||||
 | 
							return authenticatedUsersByGuid[userGuid] = userInfo;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public void Remove(Guid userGuid) {
 | 
				
			||||||
 | 
							authenticatedUsersByGuid.Remove(userGuid, out _);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readonly record struct LoggedInUser(AuthenticatedUserInfo? AuthenticatedUserInfo) {
 | 
				
			||||||
 | 
						public Guid? Guid => AuthenticatedUserInfo?.Guid;
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public bool CheckPermission(Permission permission) {
 | 
				
			||||||
 | 
							return AuthenticatedUserInfo is {} info && info.CheckPermission(permission);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public bool HasAccessToAgent(Guid agentGuid) {
 | 
				
			||||||
 | 
							return AuthenticatedUserInfo is {} info && info.HasAccessToAgent(agentGuid);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) {
 | 
				
			||||||
 | 
							return AuthenticatedUserInfo is {} info ? info.FilterAccessibleAgentGuids(agentGuids) : ImmutableHashSet<Guid>.Empty;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,140 @@
 | 
				
			|||||||
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using System.Security.Cryptography;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sealed class UserLoginManager {
 | 
				
			||||||
 | 
						private const int SessionIdBytes = 20;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private readonly AuthenticatedUserCache authenticatedUserCache;
 | 
				
			||||||
 | 
						private readonly UserManager userManager;
 | 
				
			||||||
 | 
						private readonly IDbContextProvider dbProvider;
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						private readonly UserSessionBucket[] sessionBuckets = new UserSessionBucket[256];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public UserLoginManager(AuthenticatedUserCache authenticatedUserCache, UserManager userManager, IDbContextProvider dbProvider) {
 | 
				
			||||||
 | 
							this.authenticatedUserCache = authenticatedUserCache;
 | 
				
			||||||
 | 
							this.userManager = userManager;
 | 
				
			||||||
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for (int i = 0; i < sessionBuckets.GetLength(0); i++) {
 | 
				
			||||||
 | 
								sessionBuckets[i] = new UserSessionBucket();
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private UserSessionBucket GetSessionBucket(ImmutableArray<byte> token) {
 | 
				
			||||||
 | 
							return sessionBuckets[token[0]];
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public async Task<Optional<LogInSuccess>> LogIn(string username, string password) {
 | 
				
			||||||
 | 
							Guid userGuid;
 | 
				
			||||||
 | 
							AuthenticatedUserInfo? authenticatedUserInfo;
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							await using (var db = dbProvider.Lazy()) {
 | 
				
			||||||
 | 
								var userRepository = new UserRepository(db);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								var user = await userRepository.GetByName(username);
 | 
				
			||||||
 | 
								if (user == null || !UserPasswords.Verify(password, user.PasswordHash)) {
 | 
				
			||||||
 | 
									return default;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								authenticatedUserInfo = await authenticatedUserCache.Update(user, db);
 | 
				
			||||||
 | 
								if (authenticatedUserInfo == null) {
 | 
				
			||||||
 | 
									return default;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								userGuid = user.UserGuid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
 | 
				
			||||||
 | 
								auditLogWriter.UserLoggedIn(user);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								await db.Ctx.SaveChangesAsync();
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var authToken = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
 | 
				
			||||||
 | 
							GetSessionBucket(authToken).Add(userGuid, authToken);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							return new LogInSuccess(authenticatedUserInfo, authToken);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public async Task LogOut(Guid userGuid, ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
							if (!GetSessionBucket(authToken).Remove(userGuid, authToken)) {
 | 
				
			||||||
 | 
								return;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
 | 
				
			||||||
 | 
							auditLogWriter.UserLoggedOut(userGuid);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await db.Ctx.SaveChangesAsync();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public LoggedInUser GetLoggedInUser(ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
							var userGuid = GetSessionBucket(authToken).FindUserGuid(authToken);
 | 
				
			||||||
 | 
							return userGuid != null && authenticatedUserCache.TryGet(userGuid.Value, out var userInfo) ? new LoggedInUser(userInfo) : default;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public AuthenticatedUserInfo? GetAuthenticatedUser(Guid userGuid, ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
							return authenticatedUserCache.TryGet(userGuid, out var userInfo) && GetSessionBucket(authToken).Contains(userGuid, authToken) ? userInfo : null;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private sealed class UserSessionBucket {
 | 
				
			||||||
 | 
							private ImmutableList<UserSession> sessions = ImmutableList<UserSession>.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public void Add(Guid userGuid, ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									var session = new UserSession(userGuid, authToken);
 | 
				
			||||||
 | 
									if (!sessions.Contains(session)) {
 | 
				
			||||||
 | 
										sessions = sessions.Add(session);
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public bool Contains(Guid userGuid, ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									return sessions.Contains(new UserSession(userGuid, authToken));
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public Guid? FindUserGuid(ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									return sessions.Find(session => session.AuthTokenEquals(authToken))?.UserGuid;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public bool Remove(Guid userGuid, ImmutableArray<byte> authToken) {
 | 
				
			||||||
 | 
								lock (this) {
 | 
				
			||||||
 | 
									int index = sessions.IndexOf(new UserSession(userGuid, authToken));
 | 
				
			||||||
 | 
									if (index == -1) {
 | 
				
			||||||
 | 
										return false;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									sessions = sessions.RemoveAt(index);
 | 
				
			||||||
 | 
									return true;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private sealed record UserSession(Guid UserGuid, ImmutableArray<byte> AuthToken) {
 | 
				
			||||||
 | 
							public bool AuthTokenEquals(ImmutableArray<byte> other) {
 | 
				
			||||||
 | 
								return CryptographicOperations.FixedTimeEquals(AuthToken.AsSpan(), other.AsSpan());
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public bool Equals(UserSession? other) {
 | 
				
			||||||
 | 
								if (ReferenceEquals(null, other)) {
 | 
				
			||||||
 | 
									return false;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								return UserGuid.Equals(other.UserGuid) && AuthTokenEquals(other.AuthToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							public override int GetHashCode() {
 | 
				
			||||||
 | 
								throw new NotImplementedException();
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,107 +0,0 @@
 | 
				
			|||||||
using System.Collections.Concurrent;
 | 
					 | 
				
			||||||
using System.Collections.Immutable;
 | 
					 | 
				
			||||||
using System.Runtime.CompilerServices;
 | 
					 | 
				
			||||||
using System.Security.Cryptography;
 | 
					 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					 | 
				
			||||||
using Phantom.Controller.Database.Repositories;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace Phantom.Controller.Services.Users; 
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
sealed class UserLoginManager {
 | 
					 | 
				
			||||||
	private const int SessionIdBytes = 20;
 | 
					 | 
				
			||||||
	private readonly ConcurrentDictionary<Guid, UserSession> sessionsByUserGuid = new ();
 | 
					 | 
				
			||||||
	
 | 
					 | 
				
			||||||
	private readonly UserManager userManager;
 | 
					 | 
				
			||||||
	private readonly PermissionManager permissionManager;
 | 
					 | 
				
			||||||
	private readonly IDbContextProvider dbProvider;
 | 
					 | 
				
			||||||
	
 | 
					 | 
				
			||||||
	public UserLoginManager(UserManager userManager, PermissionManager permissionManager, IDbContextProvider dbProvider) {
 | 
					 | 
				
			||||||
		this.userManager = userManager;
 | 
					 | 
				
			||||||
		this.permissionManager = permissionManager;
 | 
					 | 
				
			||||||
		this.dbProvider = dbProvider;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public async Task<LogInSuccess?> LogIn(string username, string password) {
 | 
					 | 
				
			||||||
		var user = await userManager.GetAuthenticated(username, password);
 | 
					 | 
				
			||||||
		if (user == null) {
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		var permissions = await permissionManager.FetchPermissionsForUserId(user.UserGuid);
 | 
					 | 
				
			||||||
		var userInfo = new AuthenticatedUserInfo(user.UserGuid, user.Name, permissions);
 | 
					 | 
				
			||||||
		var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		sessionsByUserGuid.AddOrUpdate(user.UserGuid, UserSession.Create, UserSession.Add, new NewUserSession(userInfo, token));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		await using (var db = dbProvider.Lazy()) {
 | 
					 | 
				
			||||||
			var auditLogWriter = new AuditLogRepository(db).Writer(user.UserGuid);
 | 
					 | 
				
			||||||
			auditLogWriter.UserLoggedIn(user);
 | 
					 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			await db.Ctx.SaveChangesAsync();
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return new LogInSuccess(userInfo, token);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public async Task LogOut(Guid userGuid, ImmutableArray<byte> token) {
 | 
					 | 
				
			||||||
		while (true) {
 | 
					 | 
				
			||||||
			if (!sessionsByUserGuid.TryGetValue(userGuid, out var oldSession)) {
 | 
					 | 
				
			||||||
				return;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			if (sessionsByUserGuid.TryUpdate(userGuid, oldSession.RemoveToken(token), oldSession)) {
 | 
					 | 
				
			||||||
				break;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
 | 
					 | 
				
			||||||
		auditLogWriter.UserLoggedOut(userGuid);
 | 
					 | 
				
			||||||
			
 | 
					 | 
				
			||||||
		await db.Ctx.SaveChangesAsync();
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public AuthenticatedUserInfo? GetAuthenticatedUser(Guid userGuid, ImmutableArray<byte> token) {
 | 
					 | 
				
			||||||
		return sessionsByUserGuid.TryGetValue(userGuid, out var session) && session.Tokens.Contains(token, TokenEqualityComparer.Instance) ? session.UserInfo : null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	private readonly record struct NewUserSession(AuthenticatedUserInfo UserInfo, ImmutableArray<byte> Token);
 | 
					 | 
				
			||||||
	
 | 
					 | 
				
			||||||
	private sealed record UserSession(AuthenticatedUserInfo UserInfo, ImmutableList<ImmutableArray<byte>> Tokens) {
 | 
					 | 
				
			||||||
		public static UserSession Create(Guid userGuid, NewUserSession newSession) {
 | 
					 | 
				
			||||||
			return new UserSession(newSession.UserInfo, ImmutableList.Create(newSession.Token));
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		public static UserSession Add(Guid userGuid, UserSession oldSession, NewUserSession newSession) {
 | 
					 | 
				
			||||||
			return new UserSession(newSession.UserInfo, oldSession.Tokens.Add(newSession.Token));
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		public UserSession RemoveToken(ImmutableArray<byte> token) {
 | 
					 | 
				
			||||||
			return this with { Tokens = Tokens.Remove(token, TokenEqualityComparer.Instance) };
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		public bool Equals(UserSession? other) {
 | 
					 | 
				
			||||||
			return ReferenceEquals(this, other);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		public override int GetHashCode() {
 | 
					 | 
				
			||||||
			return RuntimeHelpers.GetHashCode(this);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	private sealed class TokenEqualityComparer : IEqualityComparer<ImmutableArray<byte>> {
 | 
					 | 
				
			||||||
		public static TokenEqualityComparer Instance { get; } = new ();
 | 
					 | 
				
			||||||
		
 | 
					 | 
				
			||||||
		private TokenEqualityComparer() {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		public bool Equals(ImmutableArray<byte> x, ImmutableArray<byte> y) {
 | 
					 | 
				
			||||||
			return x.SequenceEqual(y);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		public int GetHashCode(ImmutableArray<byte> obj) {
 | 
					 | 
				
			||||||
			throw new NotImplementedException();
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,8 +1,10 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
using Phantom.Controller.Database.Entities;
 | 
					using Phantom.Controller.Database.Entities;
 | 
				
			||||||
using Phantom.Controller.Database.Repositories;
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Serilog;
 | 
					using Serilog;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -11,9 +13,13 @@ namespace Phantom.Controller.Services.Users;
 | 
				
			|||||||
sealed class UserManager {
 | 
					sealed class UserManager {
 | 
				
			||||||
	private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
 | 
						private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private readonly AuthenticatedUserCache authenticatedUserCache;
 | 
				
			||||||
 | 
						private readonly ControllerState controllerState;
 | 
				
			||||||
	private readonly IDbContextProvider dbProvider;
 | 
						private readonly IDbContextProvider dbProvider;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public UserManager(IDbContextProvider dbProvider) {
 | 
						public UserManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
 | 
				
			||||||
 | 
							this.authenticatedUserCache = authenticatedUserCache;
 | 
				
			||||||
 | 
							this.controllerState = controllerState;
 | 
				
			||||||
		this.dbProvider = dbProvider;
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -85,10 +91,14 @@ sealed class UserManager {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password) {
 | 
						public async Task<Result<CreateUserResult, UserActionFailure>> Create(LoggedInUser loggedInUser, string username, string password) {
 | 
				
			||||||
 | 
							if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
		var userRepository = new UserRepository(db);
 | 
							var userRepository = new UserRepository(db);
 | 
				
			||||||
		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
 | 
							var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			var result = await userRepository.CreateUser(username, password);
 | 
								var result = await userRepository.CreateUser(username, password);
 | 
				
			||||||
@@ -109,7 +119,11 @@ sealed class UserManager {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public async Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid) {
 | 
						public async Task<Result<DeleteUserResult, UserActionFailure>> DeleteByGuid(LoggedInUser loggedInUser, Guid userGuid) {
 | 
				
			||||||
 | 
							if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
		var userRepository = new UserRepository(db);
 | 
							var userRepository = new UserRepository(db);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -118,12 +132,18 @@ sealed class UserManager {
 | 
				
			|||||||
			return DeleteUserResult.NotFound;
 | 
								return DeleteUserResult.NotFound;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
 | 
							authenticatedUserCache.Remove(userGuid);
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			userRepository.DeleteUser(user);
 | 
								userRepository.DeleteUser(user);
 | 
				
			||||||
			auditLogWriter.UserDeleted(user);
 | 
								auditLogWriter.UserDeleted(user);
 | 
				
			||||||
			await db.Ctx.SaveChangesAsync();
 | 
								await db.Ctx.SaveChangesAsync();
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
 | 
								// 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);
 | 
								Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
 | 
				
			||||||
			return DeleteUserResult.Deleted;
 | 
								return DeleteUserResult.Deleted;
 | 
				
			||||||
		} catch (Exception e) {
 | 
							} catch (Exception e) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Controller.Database;
 | 
					using Phantom.Controller.Database;
 | 
				
			||||||
using Phantom.Controller.Database.Repositories;
 | 
					using Phantom.Controller.Database.Repositories;
 | 
				
			||||||
 | 
					using Phantom.Controller.Services.Users.Sessions;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Serilog;
 | 
					using Serilog;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -10,9 +12,13 @@ namespace Phantom.Controller.Services.Users;
 | 
				
			|||||||
sealed class UserRoleManager {
 | 
					sealed class UserRoleManager {
 | 
				
			||||||
	private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
 | 
						private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private readonly AuthenticatedUserCache authenticatedUserCache;
 | 
				
			||||||
 | 
						private readonly ControllerState controllerState;
 | 
				
			||||||
	private readonly IDbContextProvider dbProvider;
 | 
						private readonly IDbContextProvider dbProvider;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public UserRoleManager(IDbContextProvider dbProvider) {
 | 
						public UserRoleManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
 | 
				
			||||||
 | 
							this.authenticatedUserCache = authenticatedUserCache;
 | 
				
			||||||
 | 
							this.controllerState = controllerState;
 | 
				
			||||||
		this.dbProvider = dbProvider;
 | 
							this.dbProvider = dbProvider;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -21,7 +27,11 @@ sealed class UserRoleManager {
 | 
				
			|||||||
		return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids);
 | 
							return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) {
 | 
						public async Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(LoggedInUser loggedInUser, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) {
 | 
				
			||||||
 | 
							if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		await using var db = dbProvider.Lazy();
 | 
							await using var db = dbProvider.Lazy();
 | 
				
			||||||
		var userRepository = new UserRepository(db);
 | 
							var userRepository = new UserRepository(db);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
@@ -32,7 +42,7 @@ sealed class UserRoleManager {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		var roleRepository = new RoleRepository(db);
 | 
							var roleRepository = new RoleRepository(db);
 | 
				
			||||||
		var userRoleRepository = new UserRoleRepository(db);
 | 
							var userRoleRepository = new UserRoleRepository(db);
 | 
				
			||||||
		var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid);
 | 
							var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids));
 | 
							var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids));
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
@@ -62,6 +72,9 @@ sealed class UserRoleManager {
 | 
				
			|||||||
			auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames);
 | 
								auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames);
 | 
				
			||||||
			await db.Ctx.SaveChangesAsync();
 | 
								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);
 | 
								Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
 | 
				
			||||||
			return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable());
 | 
								return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable());
 | 
				
			||||||
		} catch (Exception e) {
 | 
							} catch (Exception e) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@ WORKDIR /data
 | 
				
			|||||||
COPY --from=eclipse-temurin:8-jre  /opt/java/openjdk /opt/java/8
 | 
					COPY --from=eclipse-temurin:8-jre  /opt/java/openjdk /opt/java/8
 | 
				
			||||||
COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16
 | 
					COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16
 | 
				
			||||||
COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17
 | 
					COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17
 | 
				
			||||||
COPY --from=eclipse-temurin:20-jre /opt/java/openjdk /opt/java/20
 | 
					COPY --from=eclipse-temurin:21-jre /opt/java/openjdk /opt/java/21
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ARG DEBIAN_FRONTEND=noninteractive
 | 
					ARG DEBIAN_FRONTEND=noninteractive
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,10 @@ public static class TaskExtensions {
 | 
				
			|||||||
		return task.ContinueOnActor(result => mapper(result, arg));
 | 
							return task.ContinueOnActor(result => mapper(result, arg));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						public static Task<TResult> ContinueOnActor<TSource, TArg1, TArg2, TResult>(this Task<TSource> task, Func<TSource, TArg1, TArg2, TResult> mapper, TArg1 arg1, TArg2 arg2) {
 | 
				
			||||||
 | 
							return task.ContinueOnActor(result => mapper(result, arg1, arg2));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	private static Task<TResult> MapResult<TSource, TResult>(Task<TSource> task, Func<TSource, TResult> mapper, TaskCompletionSource<TResult> completionSource) {
 | 
						private static Task<TResult> MapResult<TSource, TResult>(Task<TSource> task, Func<TSource, TResult> mapper, TaskCompletionSource<TResult> completionSource) {
 | 
				
			||||||
		if (task.IsFaulted) {
 | 
							if (task.IsFaulted) {
 | 
				
			||||||
			completionSource.SetException(task.Exception.InnerExceptions);
 | 
								completionSource.SetException(task.Exception.InnerExceptions);
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								Utils/Phantom.Utils/Result/Err.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								Utils/Phantom.Utils/Result/Err.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					namespace Phantom.Utils.Result;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed record Err<T>(T Error) : Result;
 | 
				
			||||||
							
								
								
									
										3
									
								
								Utils/Phantom.Utils/Result/Ok.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								Utils/Phantom.Utils/Result/Ok.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					namespace Phantom.Utils.Result;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed record Ok<T>(T Value) : Result;
 | 
				
			||||||
							
								
								
									
										5
									
								
								Utils/Phantom.Utils/Result/Result.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Utils/Phantom.Utils/Result/Result.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					namespace Phantom.Utils.Result;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public abstract record Result {
 | 
				
			||||||
 | 
						private protected Result() {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
@using Phantom.Web.Components.Utils
 | 
					@using Phantom.Web.Components.Utils
 | 
				
			||||||
@if (messageLines.Length > 0) {
 | 
					@if (messageLines.Length > 0) {
 | 
				
			||||||
  <div class="form-submit-errors text-danger">
 | 
					  <div class="form-submit-errors text-danger" role="alert">
 | 
				
			||||||
    @for (int i = 0; i < messageLines.Length; i++) {
 | 
					    @for (int i = 0; i < messageLines.Length; i++) {
 | 
				
			||||||
      @messageLines[i]
 | 
					      @messageLines[i]
 | 
				
			||||||
      if (i < messageLines.Length - 1) {
 | 
					      if (i < messageLines.Length - 1) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,9 +17,9 @@ public abstract class PhantomComponent : ComponentBase, IDisposable {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	protected CancellationToken CancellationToken => cancellationTokenSource.Token;
 | 
						protected CancellationToken CancellationToken => cancellationTokenSource.Token;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected async Task<Guid?> GetUserGuid() {
 | 
						protected async Task<AuthenticatedUser?> GetAuthenticatedUser() {
 | 
				
			||||||
		var authenticationState = await AuthenticationStateTask;
 | 
							var authenticationState = await AuthenticationStateTask;
 | 
				
			||||||
		return authenticationState.TryGetGuid();
 | 
							return authenticationState.GetAuthenticatedUser();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	protected async Task<bool> CheckPermission(Permission permission) {
 | 
						protected async Task<bool> CheckPermission(Permission permission) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,7 @@
 | 
				
			|||||||
using Phantom.Common.Data.Web.Agent;
 | 
					using Phantom.Common.Data.Web.Agent;
 | 
				
			||||||
using Phantom.Utils.Events;
 | 
					using Phantom.Utils.Events;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Agents; 
 | 
					namespace Phantom.Web.Services.Agents; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -18,7 +19,13 @@ public sealed class AgentManager {
 | 
				
			|||||||
		return agents.Value;
 | 
							return agents.Value;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid() {
 | 
						public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) {
 | 
				
			||||||
		return agents.Value.ToImmutableDictionary(static agent => agent.AgentGuid);
 | 
							if (authenticatedUser == null) {
 | 
				
			||||||
 | 
								return ImmutableDictionary<Guid, Agent>.Empty;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							return agents.Value
 | 
				
			||||||
 | 
							             .Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
 | 
				
			||||||
 | 
							             .ToImmutableDictionary(static agent => agent.AgentGuid);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace Phantom.Web.Services.Authentication;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed record AuthenticatedUser(AuthenticatedUserInfo Info, ImmutableArray<byte> Token);
 | 
				
			||||||
@@ -5,23 +5,27 @@ using Phantom.Common.Data.Web.Users;
 | 
				
			|||||||
namespace Phantom.Web.Services.Authentication;
 | 
					namespace Phantom.Web.Services.Authentication;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public static class AuthenticationStateExtensions {
 | 
					public static class AuthenticationStateExtensions {
 | 
				
			||||||
	public static Guid? TryGetGuid(this AuthenticationState authenticationState) {
 | 
						public static AuthenticatedUser? GetAuthenticatedUser(this AuthenticationState authenticationState) {
 | 
				
			||||||
		return authenticationState.User is CustomClaimsPrincipal customUser ? customUser.UserInfo.Guid : null;
 | 
							return authenticationState.User.GetAuthenticatedUser();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public static PermissionSet GetPermissions(this ClaimsPrincipal user) {
 | 
						public static AuthenticatedUser? GetAuthenticatedUser(this ClaimsPrincipal claimsPrincipal) {
 | 
				
			||||||
		return user is CustomClaimsPrincipal customUser ? customUser.UserInfo.Permissions : PermissionSet.None;
 | 
							return claimsPrincipal is CustomClaimsPrincipal principal ? principal.User : null;
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	public static bool CheckPermission(this ClaimsPrincipal user, Permission permission) {
 | 
					 | 
				
			||||||
		return user.GetPermissions().Check(permission);
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public static PermissionSet GetPermissions(this AuthenticationState authenticationState) {
 | 
						public static PermissionSet GetPermissions(this AuthenticationState authenticationState) {
 | 
				
			||||||
		return authenticationState.User.GetPermissions();
 | 
							return authenticationState.User.GetPermissions();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
 | 
						public static PermissionSet GetPermissions(this ClaimsPrincipal claimsPrincipal) {
 | 
				
			||||||
 | 
							return claimsPrincipal.GetAuthenticatedUser() is {} user ? user.Info.Permissions : PermissionSet.None;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public static bool CheckPermission(this AuthenticationState authenticationState, Permission permission) {
 | 
						public static bool CheckPermission(this AuthenticationState authenticationState, Permission permission) {
 | 
				
			||||||
		return authenticationState.User.CheckPermission(permission);
 | 
							return authenticationState.User.CheckPermission(permission);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
 | 
						public static bool CheckPermission(this ClaimsPrincipal claimsPrincipal, Permission permission) {
 | 
				
			||||||
 | 
							return claimsPrincipal.GetPermissions().Check(permission);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,41 +4,118 @@ using Microsoft.AspNetCore.Components.Server;
 | 
				
			|||||||
using Phantom.Common.Data;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					using ILogger = Serilog.ILogger;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Authentication;
 | 
					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 UserSessionBrowserStorage sessionBrowserStorage;
 | 
				
			||||||
	private readonly ControllerConnection controllerConnection;
 | 
						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.sessionBrowserStorage = sessionBrowserStorage;
 | 
				
			||||||
		this.controllerConnection = controllerConnection;
 | 
							this.controllerConnection = controllerConnection;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
 | 
						public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
 | 
				
			||||||
		if (!isLoaded) {
 | 
							if (!isLoaded) {
 | 
				
			||||||
			var stored = await sessionBrowserStorage.Get();
 | 
								await LoadSession();
 | 
				
			||||||
			if (stored != null) {
 | 
					 | 
				
			||||||
				var session = await controllerConnection.Send<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(new GetAuthenticatedUser(stored.UserGuid, stored.Token), TimeSpan.FromSeconds(30));
 | 
					 | 
				
			||||||
				if (session.Value is {} userInfo) {
 | 
					 | 
				
			||||||
					SetLoadedSession(userInfo);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return await base.GetAuthenticationStateAsync();
 | 
							return await base.GetAuthenticationStateAsync();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal void SetLoadedSession(AuthenticatedUserInfo user) {
 | 
						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;
 | 
							isLoaded = true;
 | 
				
			||||||
		SetAuthenticationState(Task.FromResult(new AuthenticationState(new CustomClaimsPrincipal(user))));
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal void SetUnloadedSession() {
 | 
						internal void SetUnloadedSession() {
 | 
				
			||||||
		isLoaded = false;
 | 
					 | 
				
			||||||
		SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
 | 
							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();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,10 +4,10 @@ using Phantom.Common.Data.Web.Users;
 | 
				
			|||||||
namespace Phantom.Web.Services.Authentication;
 | 
					namespace Phantom.Web.Services.Authentication;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sealed class CustomClaimsPrincipal : ClaimsPrincipal {
 | 
					sealed class CustomClaimsPrincipal : ClaimsPrincipal {
 | 
				
			||||||
	internal AuthenticatedUserInfo UserInfo { get; }
 | 
						internal AuthenticatedUser User { get; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal CustomClaimsPrincipal(AuthenticatedUserInfo userInfo) : base(GetIdentity(userInfo)) {
 | 
						internal CustomClaimsPrincipal(AuthenticatedUser user) : base(GetIdentity(user.Info)) {
 | 
				
			||||||
		UserInfo = userInfo;
 | 
							User = user;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private static ClaimsIdentity GetIdentity(AuthenticatedUserInfo userInfo) {
 | 
						private static ClaimsIdentity GetIdentity(AuthenticatedUserInfo userInfo) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
@@ -22,24 +23,23 @@ public sealed class UserLoginManager {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<bool> LogIn(string username, string password, string? returnUrl = null) {
 | 
						public async Task<bool> LogIn(string username, string password, string? returnUrl = null) {
 | 
				
			||||||
		LogInSuccess? success;
 | 
							Optional<LogInSuccess> result;
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			success = await controllerConnection.Send<LogInMessage, LogInSuccess?>(new LogInMessage(username, password), TimeSpan.FromSeconds(30));
 | 
								result = await controllerConnection.Send<LogInMessage, Optional<LogInSuccess>>(new LogInMessage(username, password), TimeSpan.FromSeconds(30));
 | 
				
			||||||
		} catch (Exception e) {
 | 
							} catch (Exception e) {
 | 
				
			||||||
			Logger.Error(e, "Could not log in {Username}.", username);
 | 
								Logger.Error(e, "Could not log in {Username}.", username);
 | 
				
			||||||
			return false;
 | 
								return false;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if (success == null) {
 | 
							if (result.Value is not var (userInfo, authToken)) {
 | 
				
			||||||
			return false;
 | 
								return false;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		Logger.Information("Successfully logged in {Username}.", username);
 | 
							Logger.Information("Successfully logged in {Username}.", username);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var userInfo = success.UserInfo;
 | 
							authenticationStateProvider.SetUnloadedSession();
 | 
				
			||||||
 | 
							await sessionBrowserStorage.Store(userInfo.Guid, authToken);
 | 
				
			||||||
		await sessionBrowserStorage.Store(userInfo.Guid, success.Token);
 | 
							await authenticationStateProvider.GetAuthenticationStateAsync();
 | 
				
			||||||
		authenticationStateProvider.SetLoadedSession(userInfo);
 | 
					 | 
				
			||||||
		await navigation.NavigateTo(returnUrl ?? string.Empty);
 | 
							await navigation.NavigateTo(returnUrl ?? string.Empty);
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		return true;
 | 
							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);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.EventLog;
 | 
					using Phantom.Common.Data.Web.EventLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Events; 
 | 
					namespace Phantom.Web.Services.Events; 
 | 
				
			||||||
@@ -12,8 +15,13 @@ public sealed class EventLogManager {
 | 
				
			|||||||
		this.controllerConnection = controllerConnection;
 | 
							this.controllerConnection = controllerConnection;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
 | 
						public async Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetMostRecentItems(AuthenticatedUser? authenticatedUser, int count, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new GetEventLogMessage(count);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ViewEvents)) {
 | 
				
			||||||
		return controllerConnection.Send<GetEventLogMessage, ImmutableArray<EventLogItem>>(message, cancellationToken);
 | 
								var message = new GetEventLogMessage(authenticatedUser.Token, count);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,9 +4,11 @@ using Phantom.Common.Data.Instance;
 | 
				
			|||||||
using Phantom.Common.Data.Minecraft;
 | 
					using Phantom.Common.Data.Minecraft;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
using Phantom.Common.Data.Web.Instance;
 | 
					using Phantom.Common.Data.Web.Instance;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
using Phantom.Utils.Events;
 | 
					using Phantom.Utils.Events;
 | 
				
			||||||
using Phantom.Utils.Logging;
 | 
					using Phantom.Utils.Logging;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Instances;
 | 
					namespace Phantom.Web.Services.Instances;
 | 
				
			||||||
@@ -31,27 +33,52 @@ public sealed class InstanceManager {
 | 
				
			|||||||
		return instances.Value;
 | 
							return instances.Value;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Instance? GetByGuid(Guid instanceGuid) {
 | 
						public Instance? GetByGuid(AuthenticatedUser? authenticatedUser, Guid instanceGuid) {
 | 
				
			||||||
		return instances.Value.GetValueOrDefault(instanceGuid);
 | 
							if (authenticatedUser == null) {
 | 
				
			||||||
 | 
								return null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							var instance = instances.Value.GetValueOrDefault(instanceGuid);
 | 
				
			||||||
 | 
							return instance != null && authenticatedUser.Info.HasAccessToAgent(instance.Configuration.AgentGuid) ? instance : null;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance(Guid loggedInUserGuid, Guid instanceGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) {
 | 
						public async Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance(AuthenticatedUser? authenticatedUser, Guid instanceGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new CreateOrUpdateInstanceMessage(loggedInUserGuid, instanceGuid, configuration);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.CreateInstances)) {
 | 
				
			||||||
		return controllerConnection.Send<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(message, cancellationToken);
 | 
								var message = new CreateOrUpdateInstanceMessage(authenticatedUser.Token, instanceGuid, configuration);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, CancellationToken cancellationToken) {
 | 
						public async Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> LaunchInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new LaunchInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) {
 | 
				
			||||||
		return controllerConnection.Send<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(message, cancellationToken);
 | 
								var message = new LaunchInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
 | 
						public async Task<Result<StopInstanceResult, UserInstanceActionFailure>> StopInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new StopInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid, stopStrategy);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) {
 | 
				
			||||||
		return controllerConnection.Send<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(message, cancellationToken);
 | 
								var message = new StopInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid, stopStrategy);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendCommandToInstance(Guid loggedInUserGuid, Guid agentGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) {
 | 
						public async Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> SendCommandToInstance(AuthenticatedUser? authenticatedUser, Guid agentGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new SendCommandToInstanceMessage(loggedInUserGuid, agentGuid, instanceGuid, command);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ControlInstances)) {
 | 
				
			||||||
		return controllerConnection.Send<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(message, cancellationToken);
 | 
								var message = new SendCommandToInstanceMessage(authenticatedUser.Token, agentGuid, instanceGuid, command);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@ public static class PhantomWebServices {
 | 
				
			|||||||
		
 | 
							
 | 
				
			||||||
		services.AddSingleton<UserManager>();
 | 
							services.AddSingleton<UserManager>();
 | 
				
			||||||
		services.AddSingleton<AuditLogManager>();
 | 
							services.AddSingleton<AuditLogManager>();
 | 
				
			||||||
 | 
							services.AddSingleton<UserSessionRefreshManager>();
 | 
				
			||||||
		services.AddScoped<UserLoginManager>();
 | 
							services.AddScoped<UserLoginManager>();
 | 
				
			||||||
		services.AddScoped<UserSessionBrowserStorage>();
 | 
							services.AddScoped<UserSessionBrowserStorage>();
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,12 +4,20 @@ using Phantom.Common.Messages.Web.ToWeb;
 | 
				
			|||||||
using Phantom.Utils.Actor;
 | 
					using Phantom.Utils.Actor;
 | 
				
			||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
using Phantom.Web.Services.Agents;
 | 
					using Phantom.Web.Services.Agents;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Instances;
 | 
					using Phantom.Web.Services.Instances;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Rpc; 
 | 
					namespace Phantom.Web.Services.Rpc; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
 | 
					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) {
 | 
						public static Props<IMessageToWeb> Factory(Init init) {
 | 
				
			||||||
		return Props<IMessageToWeb>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
 | 
							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 AgentManager agentManager;
 | 
				
			||||||
	private readonly InstanceManager instanceManager;
 | 
						private readonly InstanceManager instanceManager;
 | 
				
			||||||
	private readonly InstanceLogManager instanceLogManager;
 | 
						private readonly InstanceLogManager instanceLogManager;
 | 
				
			||||||
 | 
						private readonly UserSessionRefreshManager userSessionRefreshManager;
 | 
				
			||||||
	private readonly TaskCompletionSource<bool> registerSuccessWaiter;
 | 
						private readonly TaskCompletionSource<bool> registerSuccessWaiter;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	private ControllerMessageHandlerActor(Init init) {
 | 
						private ControllerMessageHandlerActor(Init init) {
 | 
				
			||||||
@@ -26,12 +35,14 @@ sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
 | 
				
			|||||||
		this.agentManager = init.AgentManager;
 | 
							this.agentManager = init.AgentManager;
 | 
				
			||||||
		this.instanceManager = init.InstanceManager;
 | 
							this.instanceManager = init.InstanceManager;
 | 
				
			||||||
		this.instanceLogManager = init.InstanceLogManager;
 | 
							this.instanceLogManager = init.InstanceLogManager;
 | 
				
			||||||
 | 
							this.userSessionRefreshManager = init.UserSessionRefreshManager;
 | 
				
			||||||
		this.registerSuccessWaiter = init.RegisterSuccessWaiter;
 | 
							this.registerSuccessWaiter = init.RegisterSuccessWaiter;
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		Receive<RegisterWebResultMessage>(HandleRegisterWebResult);
 | 
							Receive<RegisterWebResultMessage>(HandleRegisterWebResult);
 | 
				
			||||||
		Receive<RefreshAgentsMessage>(HandleRefreshAgents);
 | 
							Receive<RefreshAgentsMessage>(HandleRefreshAgents);
 | 
				
			||||||
		Receive<RefreshInstancesMessage>(HandleRefreshInstances);
 | 
							Receive<RefreshInstancesMessage>(HandleRefreshInstances);
 | 
				
			||||||
		Receive<InstanceOutputMessage>(HandleInstanceOutput);
 | 
							Receive<InstanceOutputMessage>(HandleInstanceOutput);
 | 
				
			||||||
 | 
							Receive<RefreshUserSessionMessage>(HandleRefreshUserSession);
 | 
				
			||||||
		Receive<ReplyMessage>(HandleReply);
 | 
							Receive<ReplyMessage>(HandleReply);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,6 +62,10 @@ sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToWeb> {
 | 
				
			|||||||
		instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
 | 
							instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private void HandleRefreshUserSession(RefreshUserSessionMessage message) {
 | 
				
			||||||
 | 
							userSessionRefreshManager.RefreshUser(message.UserGuid);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private void HandleReply(ReplyMessage message) {
 | 
						private void HandleReply(ReplyMessage message) {
 | 
				
			||||||
		connection.Receive(message);
 | 
							connection.Receive(message);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,7 @@ using Phantom.Utils.Actor;
 | 
				
			|||||||
using Phantom.Utils.Rpc.Runtime;
 | 
					using Phantom.Utils.Rpc.Runtime;
 | 
				
			||||||
using Phantom.Utils.Tasks;
 | 
					using Phantom.Utils.Tasks;
 | 
				
			||||||
using Phantom.Web.Services.Agents;
 | 
					using Phantom.Web.Services.Agents;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Instances;
 | 
					using Phantom.Web.Services.Instances;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Rpc;
 | 
					namespace Phantom.Web.Services.Rpc;
 | 
				
			||||||
@@ -13,6 +14,7 @@ public sealed class ControllerMessageHandlerFactory {
 | 
				
			|||||||
	private readonly AgentManager agentManager;
 | 
						private readonly AgentManager agentManager;
 | 
				
			||||||
	private readonly InstanceManager instanceManager;
 | 
						private readonly InstanceManager instanceManager;
 | 
				
			||||||
	private readonly InstanceLogManager instanceLogManager;
 | 
						private readonly InstanceLogManager instanceLogManager;
 | 
				
			||||||
 | 
						private readonly UserSessionRefreshManager userSessionRefreshManager;
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	private readonly TaskCompletionSource<bool> registerSuccessWaiter = AsyncTasks.CreateCompletionSource<bool>();
 | 
						private readonly TaskCompletionSource<bool> registerSuccessWaiter = AsyncTasks.CreateCompletionSource<bool>();
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
@@ -20,15 +22,17 @@ public sealed class ControllerMessageHandlerFactory {
 | 
				
			|||||||
	
 | 
						
 | 
				
			||||||
	private int messageHandlerId = 0;
 | 
						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.connection = connection;
 | 
				
			||||||
		this.agentManager = agentManager;
 | 
							this.agentManager = agentManager;
 | 
				
			||||||
		this.instanceManager = instanceManager;
 | 
							this.instanceManager = instanceManager;
 | 
				
			||||||
		this.instanceLogManager = instanceLogManager;
 | 
							this.instanceLogManager = instanceLogManager;
 | 
				
			||||||
 | 
							this.userSessionRefreshManager = userSessionRefreshManager;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public ActorRef<IMessageToWeb> Create(IActorRefFactory actorSystem) {
 | 
						public ActorRef<IMessageToWeb> Create(IActorRefFactory actorSystem) {
 | 
				
			||||||
		int id = Interlocked.Increment(ref messageHandlerId);
 | 
							var init = new ControllerMessageHandlerActor.Init(connection, agentManager, instanceManager, instanceLogManager, userSessionRefreshManager, registerSuccessWaiter);
 | 
				
			||||||
		return actorSystem.ActorOf(ControllerMessageHandlerActor.Factory(new ControllerMessageHandlerActor.Init(connection, agentManager, instanceManager, instanceLogManager, registerSuccessWaiter)), "ControllerMessageHandler-" + id);
 | 
							var name = "ControllerMessageHandler-" + Interlocked.Increment(ref messageHandlerId);
 | 
				
			||||||
 | 
							return actorSystem.ActorOf(ControllerMessageHandlerActor.Factory(init), name);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.AuditLog;
 | 
					using Phantom.Common.Data.Web.AuditLog;
 | 
				
			||||||
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Users; 
 | 
					namespace Phantom.Web.Services.Users; 
 | 
				
			||||||
@@ -12,8 +15,13 @@ public sealed class AuditLogManager {
 | 
				
			|||||||
		this.controllerConnection = controllerConnection;
 | 
							this.controllerConnection = controllerConnection;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) {
 | 
						public async Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetMostRecentItems(AuthenticatedUser? authenticatedUser, int count, CancellationToken cancellationToken) {
 | 
				
			||||||
		var message = new GetAuditLogMessage(count);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ViewAudit)) {
 | 
				
			||||||
		return controllerConnection.Send<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(message, cancellationToken);
 | 
								var message = new GetAuditLogMessage(authenticatedUser.Token, count);
 | 
				
			||||||
 | 
								return await controllerConnection.Send<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(message, cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Users;
 | 
					namespace Phantom.Web.Services.Users;
 | 
				
			||||||
@@ -16,11 +18,21 @@ public sealed class UserManager {
 | 
				
			|||||||
		return controllerConnection.Send<GetUsersMessage, ImmutableArray<UserInfo>>(new GetUsersMessage(), cancellationToken);
 | 
							return controllerConnection.Send<GetUsersMessage, ImmutableArray<UserInfo>>(new GetUsersMessage(), cancellationToken);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password, CancellationToken cancellationToken) {
 | 
						public async Task<Result<CreateUserResult, UserActionFailure>> Create(AuthenticatedUser? authenticatedUser, string username, string password, CancellationToken cancellationToken) {
 | 
				
			||||||
		return controllerConnection.Send<CreateUserMessage, CreateUserResult>(new CreateUserMessage(loggedInUserGuid, username, password), cancellationToken);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return await controllerConnection.Send<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(new CreateUserMessage(authenticatedUser.Token, username, password), cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
						
 | 
				
			||||||
	public Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid, CancellationToken cancellationToken) {
 | 
						public async Task<Result<DeleteUserResult, UserActionFailure>> DeleteByGuid(AuthenticatedUser? authenticatedUser, Guid userGuid, CancellationToken cancellationToken) {
 | 
				
			||||||
		return controllerConnection.Send<DeleteUserMessage, DeleteUserResult>(new DeleteUserMessage(loggedInUserGuid, userGuid), cancellationToken);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return await controllerConnection.Send<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(new DeleteUserMessage(authenticatedUser.Token, userGuid), cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Common.Messages.Web.ToController;
 | 
					using Phantom.Common.Messages.Web.ToController;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
using Phantom.Web.Services.Rpc;
 | 
					using Phantom.Web.Services.Rpc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Services.Users;
 | 
					namespace Phantom.Web.Services.Users;
 | 
				
			||||||
@@ -20,7 +22,12 @@ public sealed class UserRoleManager {
 | 
				
			|||||||
		return (await GetUserRoles(ImmutableHashSet.Create(userGuid), cancellationToken)).GetValueOrDefault(userGuid, ImmutableArray<Guid>.Empty);
 | 
							return (await GetUserRoles(ImmutableHashSet.Create(userGuid), cancellationToken)).GetValueOrDefault(userGuid, ImmutableArray<Guid>.Empty);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids, CancellationToken cancellationToken) {
 | 
						public async Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(AuthenticatedUser? authenticatedUser, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids, CancellationToken cancellationToken) {
 | 
				
			||||||
		return controllerConnection.Send<ChangeUserRolesMessage, ChangeUserRolesResult>(new ChangeUserRolesMessage(loggedInUserGuid, subjectUserGuid, addToRoleGuids, removeFromRoleGuids), cancellationToken);
 | 
							if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.EditUsers)) {
 | 
				
			||||||
 | 
								return await controllerConnection.Send<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(new ChangeUserRolesMessage(authenticatedUser.Token, subjectUserGuid, addToRoleGuids, removeFromRoleGuids), cancellationToken);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								return UserActionFailure.NotAuthorized;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,7 @@
 | 
				
			|||||||
            <p role="alert">You do not have permission to visit this page.</p>
 | 
					            <p role="alert">You do not have permission to visit this page.</p>
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          else {
 | 
					          else {
 | 
				
			||||||
            Navigation.NavigateTo("login" + QueryString.Create("return", Navigation.CreateReturnUrl()), forceLoad: true);
 | 
					            _ = Navigation.NavigateTo("login" + QueryString.Create("return", Navigation.CreateReturnUrl()), forceLoad: true);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        </NotAuthorized>
 | 
					        </NotAuthorized>
 | 
				
			||||||
      </AuthorizeRouteView>
 | 
					      </AuthorizeRouteView>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<div class="navbar-menu @NavMenuCssClass" @onclick="ToggleNavMenu">
 | 
					<div class="navbar-menu @NavMenuCssClass" @onclick="ToggleNavMenu">
 | 
				
			||||||
  <nav>
 | 
					  <nav>
 | 
				
			||||||
    <NavMenuItem Label="Home" Icon="home" Match="NavLinkMatch.All" />
 | 
					 | 
				
			||||||
    <AuthorizeView>
 | 
					    <AuthorizeView>
 | 
				
			||||||
      <NotAuthorized>
 | 
					      <NotAuthorized>
 | 
				
			||||||
        <NavMenuItem Label="Login" Icon="account-login" Href="login" />
 | 
					        <NavMenuItem Label="Login" Icon="account-login" Href="login" />
 | 
				
			||||||
@@ -25,6 +24,8 @@
 | 
				
			|||||||
      <Authorized>
 | 
					      <Authorized>
 | 
				
			||||||
        @{ var permissions = context.GetPermissions(); }
 | 
					        @{ var permissions = context.GetPermissions(); }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 | 
					        <NavMenuItem Label="Home" Icon="home" Match="NavLinkMatch.All" />
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        @if (permissions.Check(Permission.ViewInstances)) {
 | 
					        @if (permissions.Check(Permission.ViewInstances)) {
 | 
				
			||||||
          <NavMenuItem Label="Instances" Icon="folder" Href="instances" />
 | 
					          <NavMenuItem Label="Instances" Icon="folder" Href="instances" />
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
@page "/agents"
 | 
					@page "/agents"
 | 
				
			||||||
 | 
					@using System.Collections.Immutable
 | 
				
			||||||
@using Phantom.Common.Data.Web.Agent
 | 
					@using Phantom.Common.Data.Web.Agent
 | 
				
			||||||
@using Phantom.Utils.Collections
 | 
					@using Phantom.Utils.Collections
 | 
				
			||||||
@using Phantom.Web.Services.Agents
 | 
					@using Phantom.Web.Services.Agents
 | 
				
			||||||
@@ -46,33 +47,45 @@
 | 
				
			|||||||
        <Cell class="fw-semibold text-center text-success">Online</Cell>
 | 
					        <Cell class="fw-semibold text-center text-success">Online</Cell>
 | 
				
			||||||
        <Cell class="text-end">-</Cell>
 | 
					        <Cell class="text-end">-</Cell>
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      case AgentIsOffline:
 | 
					      case AgentIsOffline:
 | 
				
			||||||
        <Cell class="fw-semibold text-center">Offline</Cell>
 | 
					        <Cell class="fw-semibold text-center">Offline</Cell>
 | 
				
			||||||
        <Cell class="text-end">N/A</Cell>
 | 
					        <Cell class="text-end">N/A</Cell>
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      case AgentIsDisconnected status:
 | 
					      case AgentIsDisconnected status:
 | 
				
			||||||
        <Cell class="fw-semibold text-center">Offline</Cell>
 | 
					        <Cell class="fw-semibold text-center">Offline</Cell>
 | 
				
			||||||
        <Cell class="text-end">
 | 
					        <Cell class="text-end">
 | 
				
			||||||
          <TimeWithOffset Time="status.LastPingTime" />
 | 
					          <TimeWithOffset Time="status.LastPingTime" />
 | 
				
			||||||
        </Cell>
 | 
					        </Cell>
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      default:
 | 
					      default:
 | 
				
			||||||
        <Cell class="fw-semibold text-center">N/A</Cell>
 | 
					        <Cell class="fw-semibold text-center">N/A</Cell>
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  </ItemRow>
 | 
					  </ItemRow>
 | 
				
			||||||
  <NoItemsRow>
 | 
					  <NoItemsRow>
 | 
				
			||||||
    No agents registered.
 | 
					    No agents found.
 | 
				
			||||||
  </NoItemsRow>
 | 
					  </NoItemsRow>
 | 
				
			||||||
</Table>
 | 
					</Table>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private readonly TableData<Agent, Guid> agentTable = new();
 | 
					  private TableData<Agent, Guid>? agentTable;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    var authenticatedUser = await GetAuthenticatedUser();
 | 
				
			||||||
 | 
					    if (authenticatedUser == null) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override void OnInitialized() {
 | 
					 | 
				
			||||||
    AgentManager.AgentsChanged.Subscribe(this, agents => {
 | 
					    AgentManager.AgentsChanged.Subscribe(this, agents => {
 | 
				
			||||||
      var sortedAgents = agents.Sort(static (a1, a2) => a1.Configuration.AgentName.CompareTo(a2.Configuration.AgentName));
 | 
					      var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
 | 
				
			||||||
 | 
					                               .OrderBy(static agent => agent.Configuration.AgentName)
 | 
				
			||||||
 | 
					                               .ToImmutableArray();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      agentTable ??= new TableData<Agent, Guid>();
 | 
				
			||||||
      agentTable.UpdateFrom(sortedAgents, static agent => agent.AgentGuid, static agent => agent, static (agent, _) => agent);
 | 
					      agentTable.UpdateFrom(sortedAgents, static agent => agent.AgentGuid, static agent => agent, static (agent, _) => agent);
 | 
				
			||||||
      InvokeAsync(StateHasChanged);
 | 
					      InvokeAsync(StateHasChanged);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,13 +5,18 @@
 | 
				
			|||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
@using Phantom.Web.Services.Users
 | 
					@using Phantom.Web.Services.Users
 | 
				
			||||||
@using Phantom.Web.Services.Instances
 | 
					@using Phantom.Web.Services.Instances
 | 
				
			||||||
@inherits Phantom.Web.Components.PhantomComponent
 | 
					@inherits PhantomComponent
 | 
				
			||||||
@inject AuditLogManager AuditLogManager
 | 
					@inject AuditLogManager AuditLogManager
 | 
				
			||||||
@inject InstanceManager InstanceManager
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
@inject UserManager UserManager
 | 
					@inject UserManager UserManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<h1>Audit Log</h1>
 | 
					<h1>Audit Log</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@if (loadError is {} error) {
 | 
				
			||||||
 | 
					  <p role="alert">@error</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Table TItem="AuditLogItem" Items="logItems">
 | 
					<Table TItem="AuditLogItem" Items="logItems">
 | 
				
			||||||
  <HeaderRow>
 | 
					  <HeaderRow>
 | 
				
			||||||
    <Column Class="text-end" MinWidth="200px">Time</Column>
 | 
					    <Column Class="text-end" MinWidth="200px">Time</Column>
 | 
				
			||||||
@@ -46,21 +51,25 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private CancellationTokenSource? initializationCancellationTokenSource;
 | 
					 | 
				
			||||||
  private ImmutableArray<AuditLogItem>? logItems;
 | 
					  private ImmutableArray<AuditLogItem>? logItems;
 | 
				
			||||||
 | 
					  private string? loadError;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  private ImmutableDictionary<Guid, string>? userNamesByGuid;
 | 
					  private ImmutableDictionary<Guid, string>? userNamesByGuid;
 | 
				
			||||||
  private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
					  private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override async Task OnInitializedAsync() {
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
    initializationCancellationTokenSource = new CancellationTokenSource();
 | 
					    var result = await AuditLogManager.GetMostRecentItems(await GetAuthenticatedUser(), 50, CancellationToken);
 | 
				
			||||||
    var cancellationToken = initializationCancellationTokenSource.Token;
 | 
					    if (result) {
 | 
				
			||||||
 | 
					      logItems = result.Value;
 | 
				
			||||||
    try {
 | 
					      userNamesByGuid = (await UserManager.GetAll(CancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
 | 
				
			||||||
      logItems = await AuditLogManager.GetMostRecentItems(50, cancellationToken);
 | 
					 | 
				
			||||||
      userNamesByGuid = (await UserManager.GetAll(cancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
 | 
					 | 
				
			||||||
      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
 | 
					      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
 | 
				
			||||||
    } finally {
 | 
					    }
 | 
				
			||||||
      initializationCancellationTokenSource.Dispose();
 | 
					    else {
 | 
				
			||||||
 | 
					      logItems = ImmutableArray<AuditLogItem>.Empty;
 | 
				
			||||||
 | 
					      loadError = result.Error switch {
 | 
				
			||||||
 | 
					        UserActionFailure.NotAuthorized => "You do not have permission to view the audit log.",
 | 
				
			||||||
 | 
					        _                               => "Unknown error."
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -72,10 +81,4 @@
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override void OnDisposed() {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      initializationCancellationTokenSource?.Cancel();
 | 
					 | 
				
			||||||
    } catch (ObjectDisposedException) {}
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,6 +13,11 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<h1>Event Log</h1>
 | 
					<h1>Event Log</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@if (loadError is {} error) {
 | 
				
			||||||
 | 
					  <p role="alert">@error</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Table TItem="EventLogItem" Items="logItems">
 | 
					<Table TItem="EventLogItem" Items="logItems">
 | 
				
			||||||
  <HeaderRow>
 | 
					  <HeaderRow>
 | 
				
			||||||
    <Column Class="text-end" MinWidth="200px">Time</Column>
 | 
					    <Column Class="text-end" MinWidth="200px">Time</Column>
 | 
				
			||||||
@@ -50,21 +55,25 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private CancellationTokenSource? initializationCancellationTokenSource;
 | 
					 | 
				
			||||||
  private ImmutableArray<EventLogItem>? logItems;
 | 
					  private ImmutableArray<EventLogItem>? logItems;
 | 
				
			||||||
 | 
					  private string? loadError;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
					  private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
				
			||||||
  private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
					  private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override async Task OnInitializedAsync() {
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
    initializationCancellationTokenSource = new CancellationTokenSource();
 | 
					    var result = await EventLogManager.GetMostRecentItems(await GetAuthenticatedUser(), 50, CancellationToken);
 | 
				
			||||||
    var cancellationToken = initializationCancellationTokenSource.Token;
 | 
					    if (result) {
 | 
				
			||||||
 | 
					      logItems = result.Value;
 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      logItems = await EventLogManager.GetMostRecentItems(50, cancellationToken);
 | 
					 | 
				
			||||||
      agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName);
 | 
					      agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName);
 | 
				
			||||||
      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
 | 
					      instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
 | 
				
			||||||
    } finally {
 | 
					    }
 | 
				
			||||||
      initializationCancellationTokenSource.Dispose();
 | 
					    else {
 | 
				
			||||||
 | 
					      logItems = ImmutableArray<EventLogItem>.Empty;
 | 
				
			||||||
 | 
					      loadError = result.Error switch {
 | 
				
			||||||
 | 
					        UserActionFailure.NotAuthorized => "You do not have permission to view the event log.",
 | 
				
			||||||
 | 
					        _                               => "Unknown error."
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -79,10 +88,4 @@
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override void OnDisposed() {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      initializationCancellationTokenSource?.Cancel();
 | 
					 | 
				
			||||||
    } catch (ObjectDisposedException) {}
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,18 @@
 | 
				
			|||||||
@page "/"
 | 
					@page "/"
 | 
				
			||||||
@attribute [AllowAnonymous]
 | 
					@inherits Phantom.Web.Components.PhantomComponent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<h1>Hello, world!</h1>
 | 
					<h1>Home</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Welcome to your new app.
 | 
					@if (username != null) {
 | 
				
			||||||
 | 
					   <p>Welcome back, @username!</p>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<AuthorizeView>
 | 
					@code {
 | 
				
			||||||
  <Authorized>
 | 
					
 | 
				
			||||||
    You are logged in as @context.User.Identity!.Name.
 | 
					  private string? username = null;
 | 
				
			||||||
  </Authorized>
 | 
					  
 | 
				
			||||||
</AuthorizeView>
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    username = (await GetAuthenticatedUser())?.Info.Name;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,71 +1,84 @@
 | 
				
			|||||||
@page "/instances/{InstanceGuid:guid}"
 | 
					@page "/instances/{InstanceGuid:guid}"
 | 
				
			||||||
@attribute [Authorize(Permission.ViewInstancesPolicy)]
 | 
					@attribute [Authorize(Permission.ViewInstancesPolicy)]
 | 
				
			||||||
@using Phantom.Common.Data.Instance
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Replies
 | 
					@using Phantom.Common.Data.Replies
 | 
				
			||||||
@using Phantom.Common.Data.Web.Instance
 | 
					@using Phantom.Common.Data.Web.Instance
 | 
				
			||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
 | 
					@using Phantom.Utils.Result
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Instance
 | 
				
			||||||
@using Phantom.Web.Services.Instances
 | 
					@using Phantom.Web.Services.Instances
 | 
				
			||||||
@using Phantom.Web.Services.Authorization
 | 
					@using Phantom.Web.Services.Authorization
 | 
				
			||||||
@inherits Phantom.Web.Components.PhantomComponent
 | 
					@inherits PhantomComponent
 | 
				
			||||||
@inject InstanceManager InstanceManager
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@if (isLoading) {
 | 
				
			||||||
 | 
					  <h1>Instance</h1>
 | 
				
			||||||
 | 
					  <p>Loading...</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@if (Instance == null) {
 | 
					@if (Instance == null) {
 | 
				
			||||||
  <h1>Instance Not Found</h1>
 | 
					  <h1>Instance Not Found</h1>
 | 
				
			||||||
  <p>Return to <a href="instances">all instances</a>.</p>
 | 
					  <p>Return to <a href="instances">all instances</a>.</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
else {
 | 
					 | 
				
			||||||
  <div class="d-flex flex-row align-items-center gap-3 mb-3">
 | 
					 | 
				
			||||||
    <h1 class="mb-0">Instance: @Instance.Configuration.InstanceName</h1>
 | 
					 | 
				
			||||||
    <span class="fs-4 text-muted">//</span>
 | 
					 | 
				
			||||||
    <div class="mt-2">
 | 
					 | 
				
			||||||
      <InstanceStatusText Status="Instance.Status" />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
  <div class="d-flex flex-row align-items-center gap-2">
 | 
					 | 
				
			||||||
    <PermissionView Permission="Permission.ControlInstances">
 | 
					 | 
				
			||||||
      <button type="button" class="btn btn-success" @onclick="LaunchInstance" disabled="@(isLaunchingInstance || !Instance.Status.CanLaunch())">Launch</button>
 | 
					 | 
				
			||||||
      <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#stop-instance" disabled="@(!Instance.Status.CanStop())">Stop...</button>
 | 
					 | 
				
			||||||
      <span><!-- extra spacing --></span>
 | 
					 | 
				
			||||||
    </PermissionView>
 | 
					 | 
				
			||||||
    <PermissionView Permission="Permission.CreateInstances">
 | 
					 | 
				
			||||||
      <a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a>
 | 
					 | 
				
			||||||
    </PermissionView>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
  @if (lastError != null) {
 | 
					 | 
				
			||||||
    <p class="text-danger mt-2">@lastError</p>
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <PermissionView Permission="Permission.ViewInstanceLogs">
 | 
					 | 
				
			||||||
    <InstanceLog InstanceGuid="InstanceGuid" />
 | 
					 | 
				
			||||||
  </PermissionView>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="d-flex flex-row align-items-center gap-3 mb-3">
 | 
				
			||||||
 | 
					  <h1 class="mb-0">Instance: @Instance.Configuration.InstanceName</h1>
 | 
				
			||||||
 | 
					  <span class="fs-4 text-muted">//</span>
 | 
				
			||||||
 | 
					  <div class="mt-2">
 | 
				
			||||||
 | 
					    <InstanceStatusText Status="Instance.Status" />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<div class="d-flex flex-row align-items-center gap-2">
 | 
				
			||||||
  <PermissionView Permission="Permission.ControlInstances">
 | 
					  <PermissionView Permission="Permission.ControlInstances">
 | 
				
			||||||
    <div class="mb-3">
 | 
					    <button type="button" class="btn btn-success" @onclick="LaunchInstance" disabled="@(isLaunchingInstance || !Instance.Status.CanLaunch())">Launch</button>
 | 
				
			||||||
      <InstanceCommandInput AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" />
 | 
					    <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#stop-instance" disabled="@(!Instance.Status.CanStop())">Stop...</button>
 | 
				
			||||||
    </div>
 | 
					    <span><!-- extra spacing --></span>
 | 
				
			||||||
 | 
					 | 
				
			||||||
    <InstanceStopDialog AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" />
 | 
					 | 
				
			||||||
  </PermissionView>
 | 
					  </PermissionView>
 | 
				
			||||||
 | 
					  <PermissionView Permission="Permission.CreateInstances">
 | 
				
			||||||
 | 
					    <a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a>
 | 
				
			||||||
 | 
					  </PermissionView>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					@if (lastError != null) {
 | 
				
			||||||
 | 
					  <p class="text-danger mt-2" role="alert">@lastError</p>
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<PermissionView Permission="Permission.ViewInstanceLogs">
 | 
				
			||||||
 | 
					  <InstanceLog InstanceGuid="InstanceGuid" />
 | 
				
			||||||
 | 
					</PermissionView>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<PermissionView Permission="Permission.ControlInstances">
 | 
				
			||||||
 | 
					  <div class="my-3">
 | 
				
			||||||
 | 
					    <InstanceCommandInput AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <InstanceStopDialog AgentGuid="Instance.Configuration.AgentGuid" InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" />
 | 
				
			||||||
 | 
					</PermissionView>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  [Parameter]
 | 
					  [Parameter]
 | 
				
			||||||
  public Guid InstanceGuid { get; init; }
 | 
					  public Guid InstanceGuid { get; init; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private Instance? Instance { get; set; }
 | 
				
			||||||
 | 
					  private bool isLoading = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private string? lastError = null;
 | 
					  private string? lastError = null;
 | 
				
			||||||
  private bool isLaunchingInstance = false;
 | 
					  private bool isLaunchingInstance = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private Instance? Instance { get; set; }
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    Instance = InstanceManager.GetByGuid(await GetAuthenticatedUser(), InstanceGuid);
 | 
				
			||||||
 | 
					    isLoading = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override void OnInitialized() {
 | 
					    if (Instance != null) {
 | 
				
			||||||
    InstanceManager.InstancesChanged.Subscribe(this, instances => {
 | 
					      InstanceManager.InstancesChanged.Subscribe(this, instances => {
 | 
				
			||||||
      var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null;
 | 
					        var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null;
 | 
				
			||||||
      if (newInstance != Instance) {
 | 
					        if (newInstance != Instance) {
 | 
				
			||||||
        Instance = newInstance;
 | 
					          Instance = newInstance;
 | 
				
			||||||
        InvokeAsync(StateHasChanged);
 | 
					          InvokeAsync(StateHasChanged);
 | 
				
			||||||
      }
 | 
					        }
 | 
				
			||||||
    });
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async Task LaunchInstance() {
 | 
					  private async Task LaunchInstance() {
 | 
				
			||||||
@@ -73,20 +86,32 @@ else {
 | 
				
			|||||||
    lastError = null;
 | 
					    lastError = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      var loggedInUserGuid = await GetUserGuid();
 | 
					 | 
				
			||||||
      if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
 | 
					 | 
				
			||||||
        lastError = "You do not have permission to launch instances.";
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (Instance == null) {
 | 
					      if (Instance == null) {
 | 
				
			||||||
        lastError = "Instance not found.";
 | 
					        lastError = "Instance not found.";
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      var result = await InstanceManager.LaunchInstance(loggedInUserGuid.Value, Instance.Configuration.AgentGuid, InstanceGuid, CancellationToken);
 | 
					      var result = await InstanceManager.LaunchInstance(await GetAuthenticatedUser(), Instance.Configuration.AgentGuid, InstanceGuid, CancellationToken);
 | 
				
			||||||
      if (!result.Is(LaunchInstanceResult.LaunchInitiated)) {
 | 
					
 | 
				
			||||||
        lastError = result.Map(Messages.ToSentence, InstanceActionFailureExtensions.ToSentence);
 | 
					      switch (result.Variant()) {
 | 
				
			||||||
 | 
					        case Ok<LaunchInstanceResult>(LaunchInstanceResult.LaunchInitiated):
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        case Ok<LaunchInstanceResult>(var launchInstanceResult):
 | 
				
			||||||
 | 
					          lastError = launchInstanceResult.ToSentence();
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        case Err<UserInstanceActionFailure>(OfInstanceActionFailure(var failure)):
 | 
				
			||||||
 | 
					          lastError = failure.ToSentence();
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        case Err<UserInstanceActionFailure>(OfUserActionFailure(UserActionFailure.NotAuthorized)):
 | 
				
			||||||
 | 
					          lastError = "You do not have permission to launch this instance.";
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        default:
 | 
				
			||||||
 | 
					          lastError = "Unknown error.";
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      isLaunchingInstance = false;
 | 
					      isLaunchingInstance = false;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,14 +6,20 @@
 | 
				
			|||||||
@inherits PhantomComponent
 | 
					@inherits PhantomComponent
 | 
				
			||||||
@inject InstanceManager InstanceManager
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@if (isLoading) {
 | 
				
			||||||
 | 
					  <h1>Edit Instance</h1>
 | 
				
			||||||
 | 
					  <p>Loading...</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@if (Instance == null) {
 | 
					@if (Instance == null) {
 | 
				
			||||||
  <h1>Instance Not Found</h1>
 | 
					  <h1>Instance Not Found</h1>
 | 
				
			||||||
  <p>Return to <a href="instances">all instances</a>.</p>
 | 
					  <p>Return to <a href="instances">all instances</a>.</p>
 | 
				
			||||||
 | 
					  return;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
else {
 | 
					
 | 
				
			||||||
  <h1>Edit Instance: @Instance.Configuration.InstanceName</h1>
 | 
					<h1>Edit Instance: @Instance.Configuration.InstanceName</h1>
 | 
				
			||||||
  <InstanceAddOrEditForm EditedInstance="Instance" />
 | 
					<InstanceAddOrEditForm EditedInstance="Instance" />
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -21,9 +27,11 @@ else {
 | 
				
			|||||||
  public Guid InstanceGuid { get; init; }
 | 
					  public Guid InstanceGuid { get; init; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private Instance? Instance { get; set; }
 | 
					  private Instance? Instance { get; set; }
 | 
				
			||||||
 | 
					  private bool isLoading = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override void OnInitialized() {
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
    Instance = InstanceManager.GetByGuid(InstanceGuid);
 | 
					    Instance = InstanceManager.GetByGuid(await GetAuthenticatedUser(), InstanceGuid);
 | 
				
			||||||
 | 
					    isLoading = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,6 +21,7 @@
 | 
				
			|||||||
    <Column Width="40%">Agent</Column>
 | 
					    <Column Width="40%">Agent</Column>
 | 
				
			||||||
    <Column Width="40%">Name</Column>
 | 
					    <Column Width="40%">Name</Column>
 | 
				
			||||||
    <Column MinWidth="215px">Status</Column>
 | 
					    <Column MinWidth="215px">Status</Column>
 | 
				
			||||||
 | 
					    <Column Class="text-center" MinWidth="120px">Players</Column>
 | 
				
			||||||
    <Column Width="20%">Version</Column>
 | 
					    <Column Width="20%">Version</Column>
 | 
				
			||||||
    <Column Class="text-center" MinWidth="110px">Server Port</Column>
 | 
					    <Column Class="text-center" MinWidth="110px">Server Port</Column>
 | 
				
			||||||
    <Column Class="text-center" MinWidth="110px">Rcon Port</Column>
 | 
					    <Column Class="text-center" MinWidth="110px">Rcon Port</Column>
 | 
				
			||||||
@@ -40,6 +41,14 @@
 | 
				
			|||||||
    <Cell>
 | 
					    <Cell>
 | 
				
			||||||
      <InstanceStatusText Status="instance.Status" />
 | 
					      <InstanceStatusText Status="instance.Status" />
 | 
				
			||||||
    </Cell>
 | 
					    </Cell>
 | 
				
			||||||
 | 
					    <Cell class="text-center">
 | 
				
			||||||
 | 
					      @if (instance.PlayerCounts is var (online, maximum)) {
 | 
				
			||||||
 | 
						      <p class="font-monospace">@online.ToString() / @maximum.ToString()</p>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      else {
 | 
				
			||||||
 | 
						      <p class="font-monospace">-</p>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    </Cell>
 | 
				
			||||||
    <Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell>
 | 
					    <Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell>
 | 
				
			||||||
    <Cell class="text-center">
 | 
					    <Cell class="text-center">
 | 
				
			||||||
      <p class="font-monospace">@configuration.ServerPort.ToString()</p>
 | 
					      <p class="font-monospace">@configuration.ServerPort.ToString()</p>
 | 
				
			||||||
@@ -69,9 +78,18 @@
 | 
				
			|||||||
      this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName);
 | 
					      this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName);
 | 
				
			||||||
      InvokeAsync(StateHasChanged);
 | 
					      InvokeAsync(StateHasChanged);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    var authenticatedUser = await GetAuthenticatedUser();
 | 
				
			||||||
 | 
					    if (authenticatedUser == null) {
 | 
				
			||||||
 | 
					      instances = ImmutableArray<Instance>.Empty;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    InstanceManager.InstancesChanged.Subscribe(this, instances => {
 | 
					    InstanceManager.InstancesChanged.Subscribe(this, instances => {
 | 
				
			||||||
      this.instances = instances.Values
 | 
					      this.instances = instances.Values
 | 
				
			||||||
 | 
					                                .Where(instance => authenticatedUser.Info.HasAccessToAgent(instance.Configuration.AgentGuid))
 | 
				
			||||||
                                .OrderBy(instance => agentNamesByGuid.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty)
 | 
					                                .OrderBy(instance => agentNamesByGuid.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty)
 | 
				
			||||||
                                .ThenBy(static instance => instance.Configuration.InstanceName)
 | 
					                                .ThenBy(static instance => instance.Configuration.InstanceName)
 | 
				
			||||||
                                .ToImmutableArray();
 | 
					                                .ToImmutableArray();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,7 +60,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private Guid? me = Guid.Empty;
 | 
					  private Guid? me = null;
 | 
				
			||||||
  private ImmutableArray<UserInfo>? allUsers;
 | 
					  private ImmutableArray<UserInfo>? allUsers;
 | 
				
			||||||
  private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty;
 | 
					  private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty;
 | 
				
			||||||
  private readonly Dictionary<Guid, string> userGuidToRoleDescription = new ();
 | 
					  private readonly Dictionary<Guid, string> userGuidToRoleDescription = new ();
 | 
				
			||||||
@@ -71,7 +71,7 @@
 | 
				
			|||||||
  private UserDeleteDialog userDeleteDialog = null!;
 | 
					  private UserDeleteDialog userDeleteDialog = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override async Task OnInitializedAsync() {
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
    me = await GetUserGuid();
 | 
					    me = (await GetAuthenticatedUser())?.Info.Guid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    allUsers = (await UserManager.GetAll(CancellationToken)).Sort(static (a, b) => a.Name.CompareTo(b.Name));
 | 
					    allUsers = (await UserManager.GetAll(CancellationToken)).Sort(static (a, b) => a.Name.CompareTo(b.Name));
 | 
				
			||||||
    allRolesByGuid = (await RoleManager.GetAll(CancellationToken)).ToImmutableDictionary(static role => role.Guid, static role => role);
 | 
					    allRolesByGuid = (await RoleManager.GetAll(CancellationToken)).ToImmutableDictionary(static role => role.Guid, static role => role);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,13 +2,14 @@
 | 
				
			|||||||
@using System.Collections.Immutable
 | 
					@using System.Collections.Immutable
 | 
				
			||||||
@using System.ComponentModel.DataAnnotations
 | 
					@using System.ComponentModel.DataAnnotations
 | 
				
			||||||
@using System.Diagnostics.CodeAnalysis
 | 
					@using System.Diagnostics.CodeAnalysis
 | 
				
			||||||
@using Phantom.Common.Data.Minecraft
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Replies
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Web.Agent
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Web.Instance
 | 
					@using Phantom.Common.Data.Web.Instance
 | 
				
			||||||
@using Phantom.Common.Data.Web.Minecraft
 | 
					@using Phantom.Common.Data.Web.Minecraft
 | 
				
			||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
@using Phantom.Common.Messages.Web.ToController
 | 
					@using Phantom.Common.Messages.Web.ToController
 | 
				
			||||||
 | 
					@using Phantom.Utils.Result
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Replies
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Web.Agent
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Minecraft
 | 
				
			||||||
@using Phantom.Common.Data.Java
 | 
					@using Phantom.Common.Data.Java
 | 
				
			||||||
@using Phantom.Common.Data
 | 
					@using Phantom.Common.Data
 | 
				
			||||||
@using Phantom.Common.Data.Instance
 | 
					@using Phantom.Common.Data.Instance
 | 
				
			||||||
@@ -29,13 +30,14 @@
 | 
				
			|||||||
      @{
 | 
					      @{
 | 
				
			||||||
        static RenderFragment GetAgentOption(Agent agent) {
 | 
					        static RenderFragment GetAgentOption(Agent agent) {
 | 
				
			||||||
          var configuration = agent.Configuration;
 | 
					          var configuration = agent.Configuration;
 | 
				
			||||||
          return @<option value="@agent.AgentGuid">
 | 
					          return
 | 
				
			||||||
                   @configuration.AgentName
 | 
					            @<option value="@agent.AgentGuid">
 | 
				
			||||||
                   •
 | 
					              @configuration.AgentName
 | 
				
			||||||
                   @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances")
 | 
					              •
 | 
				
			||||||
                   •
 | 
					              @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances")
 | 
				
			||||||
                   @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(configuration.MaxMemory.InMegabytes) MB RAM
 | 
					              •
 | 
				
			||||||
                 </option>;
 | 
					              @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(configuration.MaxMemory.InMegabytes) MB RAM
 | 
				
			||||||
 | 
					            </option>;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      @if (EditedInstance == null) {
 | 
					      @if (EditedInstance == null) {
 | 
				
			||||||
@@ -276,10 +278,11 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override async Task OnInitializedAsync() {
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    var authenticatedUser = await GetAuthenticatedUser();
 | 
				
			||||||
    var agentJavaRuntimesTask = ControllerConnection.Send<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(new GetAgentJavaRuntimesMessage(), TimeSpan.FromSeconds(30));
 | 
					    var agentJavaRuntimesTask = ControllerConnection.Send<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(new GetAgentJavaRuntimesMessage(), TimeSpan.FromSeconds(30));
 | 
				
			||||||
    var minecraftVersionsTask = ControllerConnection.Send<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(new GetMinecraftVersionsMessage(), TimeSpan.FromSeconds(30));
 | 
					    var minecraftVersionsTask = ControllerConnection.Send<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(new GetMinecraftVersionsMessage(), TimeSpan.FromSeconds(30));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    allAgentsByGuid = AgentManager.ToDictionaryByGuid();
 | 
					    allAgentsByGuid = AgentManager.ToDictionaryByGuid(authenticatedUser);
 | 
				
			||||||
    allAgentJavaRuntimes = await agentJavaRuntimesTask;
 | 
					    allAgentJavaRuntimes = await agentJavaRuntimesTask;
 | 
				
			||||||
    allMinecraftVersions = await minecraftVersionsTask;
 | 
					    allMinecraftVersions = await minecraftVersionsTask;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -324,12 +327,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    await form.SubmitModel.StartSubmitting();
 | 
					    await form.SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var loggedInUserGuid = await GetUserGuid();
 | 
					 | 
				
			||||||
    if (loggedInUserGuid == null || !await CheckPermission(Permission.CreateInstances)) {
 | 
					 | 
				
			||||||
      form.SubmitModel.StopSubmitting("You do not have permission to edit instances.");
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    var instanceGuid = EditedInstance?.InstanceGuid ?? Guid.NewGuid();
 | 
					    var instanceGuid = EditedInstance?.InstanceGuid ?? Guid.NewGuid();
 | 
				
			||||||
    var instanceConfiguration = new InstanceConfiguration(
 | 
					    var instanceConfiguration = new InstanceConfiguration(
 | 
				
			||||||
      EditedInstance?.Configuration.AgentGuid ?? selectedAgent.AgentGuid,
 | 
					      EditedInstance?.Configuration.AgentGuid ?? selectedAgent.AgentGuid,
 | 
				
			||||||
@@ -343,12 +340,28 @@
 | 
				
			|||||||
      JvmArgumentsHelper.Split(form.JvmArguments)
 | 
					      JvmArgumentsHelper.Split(form.JvmArguments)
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var result = await InstanceManager.CreateOrUpdateInstance(loggedInUserGuid.Value, instanceGuid, instanceConfiguration, CancellationToken);
 | 
					    var result = await InstanceManager.CreateOrUpdateInstance(await GetAuthenticatedUser(), instanceGuid, instanceConfiguration, CancellationToken);
 | 
				
			||||||
    if (result.Is(CreateOrUpdateInstanceResult.Success)) {
 | 
					
 | 
				
			||||||
      await Navigation.NavigateTo("instances/" + instanceGuid);
 | 
					    switch (result.Variant()) {
 | 
				
			||||||
    }
 | 
					      case Ok<CreateOrUpdateInstanceResult>(CreateOrUpdateInstanceResult.Success):
 | 
				
			||||||
    else {
 | 
					        await Navigation.NavigateTo("instances/" + instanceGuid);
 | 
				
			||||||
      form.SubmitModel.StopSubmitting(result.Map(CreateOrUpdateInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence));
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Ok<CreateOrUpdateInstanceResult>(var createOrUpdateInstanceResult):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(createOrUpdateInstanceResult.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfInstanceActionFailure(var failure)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(failure.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfUserActionFailure(UserActionFailure.NotAuthorized)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("You do not have permission to create or edit instances.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("Unknown error.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,12 @@
 | 
				
			|||||||
@using Phantom.Web.Services.Instances
 | 
					@using Phantom.Common.Data.Replies
 | 
				
			||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
@using Phantom.Common.Data.Replies
 | 
					@using Phantom.Utils.Result
 | 
				
			||||||
 | 
					@using Phantom.Web.Services.Instances
 | 
				
			||||||
@inherits Phantom.Web.Components.PhantomComponent
 | 
					@inherits Phantom.Web.Components.PhantomComponent
 | 
				
			||||||
@inject InstanceManager InstanceManager
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Form Model="form" OnSubmit="ExecuteCommand">
 | 
					<Form Model="form" OnSubmit="ExecuteCommand">
 | 
				
			||||||
  <label for="command-input" class="form-label">Instance Name</label>
 | 
					  <label for="command-input" class="form-label">Execute Command</label>
 | 
				
			||||||
  <div class="input-group flex-nowrap">
 | 
					  <div class="input-group flex-nowrap">
 | 
				
			||||||
    <span class="input-group-text" style="padding-top: 0.3rem;">/</span>
 | 
					    <span class="input-group-text" style="padding-top: 0.3rem;">/</span>
 | 
				
			||||||
    <input id="command-input" class="form-control" type="text" placeholder="command" @bind="form.Command" @bind:event="oninput" disabled="@(Disabled || form.SubmitModel.IsSubmitting)" @ref="commandInputElement" />
 | 
					    <input id="command-input" class="form-control" type="text" placeholder="command" @bind="form.Command" @bind:event="oninput" disabled="@(Disabled || form.SubmitModel.IsSubmitting)" @ref="commandInputElement" />
 | 
				
			||||||
@@ -36,19 +37,29 @@
 | 
				
			|||||||
  private async Task ExecuteCommand(EditContext context) {
 | 
					  private async Task ExecuteCommand(EditContext context) {
 | 
				
			||||||
    await form.SubmitModel.StartSubmitting();
 | 
					    await form.SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var loggedInUserGuid = await GetUserGuid();
 | 
					    var result = await InstanceManager.SendCommandToInstance(await GetAuthenticatedUser(), AgentGuid, InstanceGuid, form.Command, CancellationToken);
 | 
				
			||||||
    if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
 | 
					 | 
				
			||||||
      form.SubmitModel.StopSubmitting("You do not have permission to execute commands.");
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var result = await InstanceManager.SendCommandToInstance(loggedInUserGuid.Value, AgentGuid, InstanceGuid, form.Command, CancellationToken);
 | 
					    switch (result.Variant()) {
 | 
				
			||||||
    if (result.Is(SendCommandToInstanceResult.Success)) {
 | 
					      case Ok<SendCommandToInstanceResult>(SendCommandToInstanceResult.Success):
 | 
				
			||||||
      form.Command = string.Empty;
 | 
					        form.Command = string.Empty;
 | 
				
			||||||
      form.SubmitModel.StopSubmitting();
 | 
					        form.SubmitModel.StopSubmitting();
 | 
				
			||||||
    }
 | 
					        break;
 | 
				
			||||||
    else {
 | 
					
 | 
				
			||||||
      form.SubmitModel.StopSubmitting(result.Map(Messages.ToSentence, InstanceActionFailureExtensions.ToSentence));
 | 
					      case Ok<SendCommandToInstanceResult>(var sendCommandToInstanceResult):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(sendCommandToInstanceResult.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfInstanceActionFailure(var failure)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(failure.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfUserActionFailure(UserActionFailure.NotAuthorized)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("You do not have permission to send commands to this instance.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("Unknown error.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    StateHasChanged();
 | 
					    StateHasChanged();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,9 @@
 | 
				
			|||||||
@using Phantom.Web.Services.Instances
 | 
					@using Phantom.Common.Data.Replies
 | 
				
			||||||
@using System.ComponentModel.DataAnnotations
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
 | 
					@using Phantom.Utils.Result
 | 
				
			||||||
 | 
					@using Phantom.Web.Services.Instances
 | 
				
			||||||
 | 
					@using System.ComponentModel.DataAnnotations
 | 
				
			||||||
@using Phantom.Common.Data.Minecraft
 | 
					@using Phantom.Common.Data.Minecraft
 | 
				
			||||||
@using Phantom.Common.Data.Replies
 | 
					 | 
				
			||||||
@inherits Phantom.Web.Components.PhantomComponent
 | 
					@inherits Phantom.Web.Components.PhantomComponent
 | 
				
			||||||
@inject IJSRuntime Js;
 | 
					@inject IJSRuntime Js;
 | 
				
			||||||
@inject InstanceManager InstanceManager;
 | 
					@inject InstanceManager InstanceManager;
 | 
				
			||||||
@@ -53,19 +54,29 @@
 | 
				
			|||||||
  private async Task StopInstance(EditContext context) {
 | 
					  private async Task StopInstance(EditContext context) {
 | 
				
			||||||
    await form.SubmitModel.StartSubmitting();
 | 
					    await form.SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var loggedInUserGuid = await GetUserGuid();
 | 
					    var result = await InstanceManager.StopInstance(await GetAuthenticatedUser(), AgentGuid, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken);
 | 
				
			||||||
    if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) {
 | 
					 | 
				
			||||||
      form.SubmitModel.StopSubmitting("You do not have permission to stop instances.");
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var result = await InstanceManager.StopInstance(loggedInUserGuid.Value, AgentGuid, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken);
 | 
					    switch (result.Variant()) {
 | 
				
			||||||
    if (result.Is(StopInstanceResult.StopInitiated)) {
 | 
					      case Ok<StopInstanceResult>(StopInstanceResult.StopInitiated):
 | 
				
			||||||
      await Js.InvokeVoidAsync("closeModal", ModalId);
 | 
					        await Js.InvokeVoidAsync("closeModal", ModalId);
 | 
				
			||||||
      form.SubmitModel.StopSubmitting();
 | 
					        form.SubmitModel.StopSubmitting();
 | 
				
			||||||
    }
 | 
					        break;
 | 
				
			||||||
    else {
 | 
					
 | 
				
			||||||
      form.SubmitModel.StopSubmitting(result.Map(Messages.ToSentence, InstanceActionFailureExtensions.ToSentence));
 | 
					      case Ok<StopInstanceResult>(var stopInstanceResult):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(stopInstanceResult.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfInstanceActionFailure(var failure)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting(failure.ToSentence());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserInstanceActionFailure>(OfUserActionFailure(UserActionFailure.NotAuthorized)):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("You do not have permission to stop this instance.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("Unknown error.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
@using Phantom.Common.Data.Web.Users.CreateUserResults
 | 
					@using Phantom.Common.Data.Web.Users.CreateUserResults
 | 
				
			||||||
 | 
					@using Phantom.Utils.Result
 | 
				
			||||||
@using Phantom.Web.Services.Users
 | 
					@using Phantom.Web.Services.Users
 | 
				
			||||||
@using System.ComponentModel.DataAnnotations
 | 
					@using System.ComponentModel.DataAnnotations
 | 
				
			||||||
@inherits Phantom.Web.Components.PhantomComponent
 | 
					@inherits Phantom.Web.Components.PhantomComponent
 | 
				
			||||||
@@ -39,7 +40,7 @@
 | 
				
			|||||||
  [Parameter]
 | 
					  [Parameter]
 | 
				
			||||||
  public EventCallback<UserInfo> UserAdded { get; set; }
 | 
					  public EventCallback<UserInfo> UserAdded { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private readonly AddUserFormModel form = new();
 | 
					  private readonly AddUserFormModel form = new ();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private sealed class AddUserFormModel : FormModel {
 | 
					  private sealed class AddUserFormModel : FormModel {
 | 
				
			||||||
    [Required]
 | 
					    [Required]
 | 
				
			||||||
@@ -52,23 +53,23 @@
 | 
				
			|||||||
  private async Task AddUser(EditContext context) {
 | 
					  private async Task AddUser(EditContext context) {
 | 
				
			||||||
    await form.SubmitModel.StartSubmitting();
 | 
					    await form.SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var loggedInUserGuid = await GetUserGuid();
 | 
					    var result = await UserManager.Create(await GetAuthenticatedUser(), form.Username, form.Password, CancellationToken);
 | 
				
			||||||
    if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) {
 | 
					 | 
				
			||||||
      form.SubmitModel.StopSubmitting("You do not have permission to add users.");
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    switch (await UserManager.Create(loggedInUserGuid.Value, form.Username, form.Password, CancellationToken)) {
 | 
					    switch (result.Variant()) {
 | 
				
			||||||
      case Success success:
 | 
					      case Ok<CreateUserResult>(Success success):
 | 
				
			||||||
        await UserAdded.InvokeAsync(success.User);
 | 
					        await UserAdded.InvokeAsync(success.User);
 | 
				
			||||||
        await Js.InvokeVoidAsync("closeModal", ModalId);
 | 
					        await Js.InvokeVoidAsync("closeModal", ModalId);
 | 
				
			||||||
        form.SubmitModel.StopSubmitting();
 | 
					        form.SubmitModel.StopSubmitting();
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      case CreationFailed fail:
 | 
					      case Ok<CreateUserResult>(CreationFailed fail):
 | 
				
			||||||
        form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n"));
 | 
					        form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n"));
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case Err<UserActionFailure>(UserActionFailure.NotAuthorized):
 | 
				
			||||||
 | 
					        form.SubmitModel.StopSubmitting("You do not have permission to add users.");
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      default:
 | 
					      default:
 | 
				
			||||||
        form.SubmitModel.StopSubmitting("Unknown error.");
 | 
					        form.SubmitModel.StopSubmitting("Unknown error.");
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
@using Phantom.Common.Data.Web.Users
 | 
					@using Phantom.Common.Data.Web.Users
 | 
				
			||||||
 | 
					@using Phantom.Web.Services.Authentication
 | 
				
			||||||
@using Phantom.Web.Services.Users
 | 
					@using Phantom.Web.Services.Users
 | 
				
			||||||
@inherits UserEditDialogBase
 | 
					@inherits UserEditDialogBase
 | 
				
			||||||
@inject UserManager UserManager
 | 
					@inject UserManager UserManager
 | 
				
			||||||
@@ -17,8 +18,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@code {
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected override async Task DoEdit(Guid loggedInUserGuid, UserInfo user) {
 | 
					  protected override async Task<UserActionFailure?> DoEdit(AuthenticatedUser? authenticatedUser, UserInfo editedUser) {
 | 
				
			||||||
    switch (await UserManager.DeleteByGuid(loggedInUserGuid, user.Guid, CancellationToken)) {
 | 
					    var result = await UserManager.DeleteByGuid(authenticatedUser, editedUser.Guid, CancellationToken);
 | 
				
			||||||
 | 
					    if (!result) {
 | 
				
			||||||
 | 
					      return result.Error;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    switch (result.Value) {
 | 
				
			||||||
      case DeleteUserResult.Deleted:
 | 
					      case DeleteUserResult.Deleted:
 | 
				
			||||||
      case DeleteUserResult.NotFound:
 | 
					      case DeleteUserResult.NotFound:
 | 
				
			||||||
        await OnEditSuccess();
 | 
					        await OnEditSuccess();
 | 
				
			||||||
@@ -28,6 +34,8 @@
 | 
				
			|||||||
        OnEditFailure("Could not delete user.");
 | 
					        OnEditFailure("Could not delete user.");
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ using Microsoft.JSInterop;
 | 
				
			|||||||
using Phantom.Common.Data.Web.Users;
 | 
					using Phantom.Common.Data.Web.Users;
 | 
				
			||||||
using Phantom.Web.Components;
 | 
					using Phantom.Web.Components;
 | 
				
			||||||
using Phantom.Web.Components.Forms;
 | 
					using Phantom.Web.Components.Forms;
 | 
				
			||||||
 | 
					using Phantom.Web.Services.Authentication;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Phantom.Web.Shared;
 | 
					namespace Phantom.Web.Shared;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -16,7 +17,7 @@ public abstract class UserEditDialogBase : PhantomComponent {
 | 
				
			|||||||
	[Parameter]
 | 
						[Parameter]
 | 
				
			||||||
	public EventCallback<UserInfo> UserModified { get; set; }
 | 
						public EventCallback<UserInfo> UserModified { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected readonly FormButtonSubmit.SubmitModel SubmitModel = new();
 | 
						protected readonly FormButtonSubmit.SubmitModel SubmitModel = new ();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private UserInfo? EditedUser { get; set; } = null;
 | 
						private UserInfo? EditedUser { get; set; } = null;
 | 
				
			||||||
	protected string EditedUserName { get; private set; } = string.Empty;
 | 
						protected string EditedUserName { get; private set; } = string.Empty;
 | 
				
			||||||
@@ -41,19 +42,26 @@ public abstract class UserEditDialogBase : PhantomComponent {
 | 
				
			|||||||
	protected async Task Submit() {
 | 
						protected async Task Submit() {
 | 
				
			||||||
		await SubmitModel.StartSubmitting();
 | 
							await SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var loggedInUserGuid = await GetUserGuid();
 | 
							if (EditedUser == null) {
 | 
				
			||||||
		if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) {
 | 
					 | 
				
			||||||
			SubmitModel.StopSubmitting("You do not have permission to edit users.");
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		else if (EditedUser == null) {
 | 
					 | 
				
			||||||
			SubmitModel.StopSubmitting("Invalid user.");
 | 
								SubmitModel.StopSubmitting("Invalid user.");
 | 
				
			||||||
 | 
								return;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		else {
 | 
					
 | 
				
			||||||
			await DoEdit(loggedInUserGuid.Value, EditedUser);
 | 
							switch (await DoEdit(await GetAuthenticatedUser(), EditedUser)) {
 | 
				
			||||||
 | 
								case null:
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								case UserActionFailure.NotAuthorized:
 | 
				
			||||||
 | 
									SubmitModel.StopSubmitting("You do not have permission to edit users.");
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								default:
 | 
				
			||||||
 | 
									SubmitModel.StopSubmitting("Unknown error.");
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected abstract Task DoEdit(Guid loggedInUserGuid, UserInfo user);
 | 
						protected abstract Task<UserActionFailure?> DoEdit(AuthenticatedUser? authenticatedUser, UserInfo editedUser);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected async Task OnEditSuccess() {
 | 
						protected async Task OnEditSuccess() {
 | 
				
			||||||
		await UserModified.InvokeAsync(EditedUser);
 | 
							await UserModified.InvokeAsync(EditedUser);
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user