mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-25 16:42:54 +01:00
Compare commits
3 Commits
a89a8738f9
...
a6bdc6db12
Author | SHA1 | Date | |
---|---|---|---|
a6bdc6db12 | |||
b2c16279c4 | |||
f1fa90e4d8 |
@ -1,7 +1,5 @@
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Common.Messages.Agent.ToController;
|
||||
using Phantom.Utils.Rpc;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Agent.Rpc;
|
||||
@ -11,10 +9,10 @@ sealed class KeepAliveLoop {
|
||||
|
||||
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
|
||||
private readonly RpcServerConnection connection;
|
||||
private readonly CancellationTokenSource cancellationTokenSource = new ();
|
||||
|
||||
public KeepAliveLoop(RpcConnectionToServer<IMessageToControllerListener> connection) {
|
||||
public KeepAliveLoop(RpcServerConnection connection) {
|
||||
this.connection = connection;
|
||||
Task.Run(Run);
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ using Serilog.Events;
|
||||
namespace Phantom.Agent.Rpc;
|
||||
|
||||
public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
||||
public static Task Launch(RpcConfiguration config, AuthToken authToken, AgentInfo agentInfo, Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
|
||||
public static Task Launch(RpcConfiguration config, AuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
|
||||
var socket = new ClientSocket();
|
||||
var options = socket.Options;
|
||||
|
||||
@ -26,12 +26,12 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
||||
|
||||
private readonly RpcConfiguration config;
|
||||
private readonly Guid agentGuid;
|
||||
private readonly Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> messageListenerFactory;
|
||||
private readonly Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory;
|
||||
|
||||
private readonly SemaphoreSlim disconnectSemaphore;
|
||||
private readonly CancellationToken receiveCancellationToken;
|
||||
|
||||
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
|
||||
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
|
||||
this.config = config;
|
||||
this.agentGuid = agentGuid;
|
||||
this.messageListenerFactory = messageListenerFactory;
|
||||
@ -49,7 +49,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
||||
}
|
||||
|
||||
protected override void Run(ClientSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) {
|
||||
var connection = new RpcConnectionToServer<IMessageToControllerListener>(socket, AgentMessageRegistries.ToController, replyTracker);
|
||||
var connection = new RpcServerConnection(socket, replyTracker);
|
||||
ServerMessaging.SetCurrentConnection(connection);
|
||||
|
||||
var logger = config.RuntimeLogger;
|
||||
|
41
Agent/Phantom.Agent.Rpc/RpcServerConnection.cs
Normal file
41
Agent/Phantom.Agent.Rpc/RpcServerConnection.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Common.Messages.Agent.BiDirectional;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
|
||||
namespace Phantom.Agent.Rpc;
|
||||
|
||||
public sealed class RpcServerConnection {
|
||||
private readonly ClientSocket socket;
|
||||
private readonly MessageReplyTracker replyTracker;
|
||||
|
||||
internal RpcServerConnection(ClientSocket socket, MessageReplyTracker replyTracker) {
|
||||
this.socket = socket;
|
||||
this.replyTracker = replyTracker;
|
||||
}
|
||||
|
||||
internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
|
||||
var bytes = AgentMessageRegistries.ToController.Write(message).ToArray();
|
||||
if (bytes.Length > 0) {
|
||||
await socket.SendAsync(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> where TReply : class {
|
||||
var sequenceId = replyTracker.RegisterReply();
|
||||
|
||||
var bytes = AgentMessageRegistries.ToController.Write<TMessage, TReply>(sequenceId, message).ToArray();
|
||||
if (bytes.Length == 0) {
|
||||
replyTracker.ForgetReply(sequenceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await socket.SendAsync(bytes);
|
||||
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
||||
}
|
||||
|
||||
public void Receive(ReplyMessage message) {
|
||||
replyTracker.ReceiveReply(message.SequenceId, message.SerializedReply);
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Utils.Rpc;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Agent.Rpc;
|
||||
@ -8,12 +7,12 @@ namespace Phantom.Agent.Rpc;
|
||||
public static class ServerMessaging {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create(nameof(ServerMessaging));
|
||||
|
||||
private static RpcConnectionToServer<IMessageToControllerListener>? CurrentConnection { get; set; }
|
||||
private static RpcConnectionToServer<IMessageToControllerListener> CurrentConnectionOrThrow => CurrentConnection ?? throw new InvalidOperationException("Server connection not ready.");
|
||||
private static RpcServerConnection? CurrentConnection { get; set; }
|
||||
private static RpcServerConnection CurrentConnectionOrThrow => CurrentConnection ?? throw new InvalidOperationException("Server connection not ready.");
|
||||
|
||||
private static readonly object SetCurrentConnectionLock = new ();
|
||||
|
||||
internal static void SetCurrentConnection(RpcConnectionToServer<IMessageToControllerListener> connection) {
|
||||
internal static void SetCurrentConnection(RpcServerConnection connection) {
|
||||
lock (SetCurrentConnectionLock) {
|
||||
if (CurrentConnection != null) {
|
||||
throw new InvalidOperationException("Server connection can only be set once.");
|
||||
|
@ -102,7 +102,7 @@ sealed class BackupManager : IDisposable {
|
||||
|
||||
private void LogBackupResult(BackupCreationResult result) {
|
||||
if (result.Kind != BackupCreationResultKind.Success) {
|
||||
logger.Warning("Backup failed: {Reason}", DescribeResult(result.Kind));
|
||||
logger.Warning("Backup failed: {Reason}", result.Kind.ToSentence());
|
||||
return;
|
||||
}
|
||||
|
||||
@ -114,19 +114,5 @@ sealed class BackupManager : IDisposable {
|
||||
logger.Information("Backup finished successfully.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeResult(BackupCreationResultKind kind) {
|
||||
return kind switch {
|
||||
BackupCreationResultKind.Success => "Backup created successfully.",
|
||||
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
|
||||
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
|
||||
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
|
||||
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
|
||||
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
|
||||
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
|
||||
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Common.Messages.Agent.BiDirectional;
|
||||
using Phantom.Common.Messages.Agent.ToAgent;
|
||||
using Phantom.Common.Messages.Agent.ToController;
|
||||
using Phantom.Utils.Rpc;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
using Serilog;
|
||||
|
||||
@ -15,11 +14,11 @@ namespace Phantom.Agent.Services.Rpc;
|
||||
public sealed class MessageListener : IMessageToAgentListener {
|
||||
private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>();
|
||||
|
||||
private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
|
||||
private readonly RpcServerConnection connection;
|
||||
private readonly AgentServices agent;
|
||||
private readonly CancellationTokenSource shutdownTokenSource;
|
||||
|
||||
public MessageListener(RpcConnectionToServer<IMessageToControllerListener> connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
|
||||
public MessageListener(RpcServerConnection connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
|
||||
this.connection = connection;
|
||||
this.agent = agent;
|
||||
this.shutdownTokenSource = shutdownTokenSource;
|
||||
|
@ -5,7 +5,6 @@ using Phantom.Agent.Services;
|
||||
using Phantom.Agent.Services.Rpc;
|
||||
using Phantom.Common.Data.Agent;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Utils.Rpc;
|
||||
using Phantom.Utils.Runtime;
|
||||
using Phantom.Utils.Tasks;
|
||||
@ -48,7 +47,7 @@ try {
|
||||
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
|
||||
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks));
|
||||
|
||||
MessageListener MessageListenerFactory(RpcConnectionToServer<IMessageToControllerListener> connection) {
|
||||
MessageListener MessageListenerFactory(RpcServerConnection connection) {
|
||||
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next.StrongName" />
|
||||
<PackageReference Include="MemoryPack" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -1,25 +1,26 @@
|
||||
using System.Collections.Immutable;
|
||||
using MemoryPack;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(NameIsInvalid))]
|
||||
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
|
||||
[MemoryPackUnion(2, typeof(NameAlreadyExists))]
|
||||
[MemoryPackUnion(3, typeof(UnknownError))]
|
||||
public abstract record AddUserError {
|
||||
private AddUserError() {}
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError;
|
||||
public sealed record NameIsInvalid(UsernameRequirementViolation Violation) : AddUserError;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
|
||||
public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record NameAlreadyExists : AddUserError;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record UnknownError : AddUserError;
|
||||
}
|
||||
|
||||
public static class AddUserErrorExtensions {
|
||||
public static string ToSentences(this AddUserError error, string delimiter) {
|
||||
return error switch {
|
||||
AddUserError.NameIsInvalid e => e.Violation.ToSentence(),
|
||||
AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
|
||||
AddUserError.NameAlreadyExists => "Username is already occupied.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +0,0 @@
|
||||
using MemoryPack;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(Success))]
|
||||
[MemoryPackUnion(1, typeof(CreationFailed))]
|
||||
[MemoryPackUnion(2, typeof(UpdatingFailed))]
|
||||
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
|
||||
[MemoryPackUnion(4, typeof(UnknownError))]
|
||||
public abstract partial record CreateOrUpdateAdministratorUserResult {
|
||||
private CreateOrUpdateAdministratorUserResult() {}
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record UpdatingFailed([property: MemoryPackOrder(0)] SetUserPasswordError Error) : CreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record AddingToRoleFailed : CreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record UnknownError : CreateOrUpdateAdministratorUserResult;
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
using MemoryPack;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(Success))]
|
||||
[MemoryPackUnion(1, typeof(CreationFailed))]
|
||||
[MemoryPackUnion(2, typeof(UpdatingFailed))]
|
||||
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
|
||||
public partial interface ICreateOrUpdateAdministratorUserResult {
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record Success(UserInfo User) : ICreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record CreationFailed(AddUserError Error) : ICreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record UpdatingFailed(SetUserPasswordError Error) : ICreateOrUpdateAdministratorUserResult;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed partial record AddingToRoleFailed : ICreateOrUpdateAdministratorUserResult;
|
||||
}
|
@ -1,24 +1,25 @@
|
||||
using MemoryPack;
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(TooShort))]
|
||||
[MemoryPackUnion(1, typeof(LowercaseLetterRequired))]
|
||||
[MemoryPackUnion(2, typeof(UppercaseLetterRequired))]
|
||||
[MemoryPackUnion(3, typeof(DigitRequired))]
|
||||
public abstract record PasswordRequirementViolation {
|
||||
private PasswordRequirementViolation() {}
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation;
|
||||
public sealed record TooShort(int MinimumLength) : PasswordRequirementViolation;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record LowercaseLetterRequired : PasswordRequirementViolation;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record UppercaseLetterRequired : PasswordRequirementViolation;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record DigitRequired : PasswordRequirementViolation;
|
||||
}
|
||||
|
||||
public static class PasswordRequirementViolationExtensions {
|
||||
public static string ToSentence(this PasswordRequirementViolation violation) {
|
||||
return violation switch {
|
||||
PasswordRequirementViolation.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.",
|
||||
PasswordRequirementViolation.LowercaseLetterRequired => "Password must contain a lowercase letter.",
|
||||
PasswordRequirementViolation.UppercaseLetterRequired => "Password must contain an uppercase letter.",
|
||||
PasswordRequirementViolation.DigitRequired => "Password must contain a digit.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,23 @@
|
||||
using System.Collections.Immutable;
|
||||
using MemoryPack;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(UserNotFound))]
|
||||
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
|
||||
[MemoryPackUnion(2, typeof(UnknownError))]
|
||||
public abstract record SetUserPasswordError {
|
||||
private SetUserPasswordError() {}
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record UserNotFound : SetUserPasswordError;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record UnknownError : SetUserPasswordError;
|
||||
}
|
||||
|
||||
public static class SetUserPasswordErrorExtensions {
|
||||
public static string ToSentences(this SetUserPasswordError error, string delimiter) {
|
||||
return error switch {
|
||||
SetUserPasswordError.UserNotFound => "User not found.",
|
||||
SetUserPasswordError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
public static class UserPasswords {
|
||||
public static string Hash(string password) {
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string hash) {
|
||||
// TODO rehash
|
||||
return BCrypt.Net.BCrypt.Verify(password, hash);
|
||||
}
|
||||
}
|
@ -1,16 +1,19 @@
|
||||
using MemoryPack;
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
|
||||
[MemoryPackable]
|
||||
[MemoryPackUnion(0, typeof(IsEmpty))]
|
||||
[MemoryPackUnion(1, typeof(TooLong))]
|
||||
public abstract record UsernameRequirementViolation {
|
||||
private UsernameRequirementViolation() {}
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record IsEmpty : UsernameRequirementViolation;
|
||||
|
||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||
public sealed record TooLong(int MaxLength) : UsernameRequirementViolation;
|
||||
}
|
||||
|
||||
public static class UsernameRequirementViolationExtensions {
|
||||
public static string ToSentence(this UsernameRequirementViolation violation) {
|
||||
return violation switch {
|
||||
UsernameRequirementViolation.IsEmpty => "Username must not be empty.",
|
||||
UsernameRequirementViolation.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -20,4 +20,18 @@ public static class BackupCreationResultSummaryExtensions {
|
||||
kind != BackupCreationResultKind.BackupAlreadyRunning &&
|
||||
kind != BackupCreationResultKind.BackupFileAlreadyExists;
|
||||
}
|
||||
|
||||
public static string ToSentence(this BackupCreationResultKind kind) {
|
||||
return kind switch {
|
||||
BackupCreationResultKind.Success => "Backup created successfully.",
|
||||
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
|
||||
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
|
||||
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
|
||||
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
|
||||
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
|
||||
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
|
||||
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -12,3 +12,20 @@ public enum InstanceLaunchFailReason : byte {
|
||||
CouldNotPrepareMinecraftServerLauncher = 8,
|
||||
CouldNotStartMinecraftServer = 9
|
||||
}
|
||||
|
||||
public static class InstanceLaunchFailReasonExtensions {
|
||||
public static string ToSentence(this InstanceLaunchFailReason reason) {
|
||||
return reason switch {
|
||||
InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.",
|
||||
InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.",
|
||||
InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.",
|
||||
InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.",
|
||||
InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.",
|
||||
InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.",
|
||||
InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.",
|
||||
InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher => "Could not prepare Minecraft server launcher.",
|
||||
InstanceLaunchFailReason.CouldNotStartMinecraftServer => "Could not start Minecraft server.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -7,3 +7,16 @@ public enum LaunchInstanceResult : byte {
|
||||
InstanceLimitExceeded = 4,
|
||||
MemoryLimitExceeded = 5
|
||||
}
|
||||
|
||||
public static class LaunchInstanceResultExtensions {
|
||||
public static string ToSentence(this LaunchInstanceResult reason) {
|
||||
return reason switch {
|
||||
LaunchInstanceResult.LaunchInitiated => "Launch initiated.",
|
||||
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
|
||||
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
|
||||
LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.",
|
||||
LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -4,3 +4,12 @@ public enum SendCommandToInstanceResult : byte {
|
||||
UnknownError,
|
||||
Success
|
||||
}
|
||||
|
||||
public static class SendCommandToInstanceResultExtensions {
|
||||
public static string ToSentence(this SendCommandToInstanceResult reason) {
|
||||
return reason switch {
|
||||
SendCommandToInstanceResult.Success => "Command sent.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -5,3 +5,14 @@ public enum StopInstanceResult : byte {
|
||||
InstanceAlreadyStopping = 2,
|
||||
InstanceAlreadyStopped = 3
|
||||
}
|
||||
|
||||
public static class StopInstanceResultExtensions {
|
||||
public static string ToSentence(this StopInstanceResult reason) {
|
||||
return reason switch {
|
||||
StopInstanceResult.StopInitiated => "Stopping initiated.",
|
||||
StopInstanceResult.InstanceAlreadyStopping => "Instance is already stopping.",
|
||||
StopInstanceResult.InstanceAlreadyStopped => "Instance is already stopped.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ namespace Phantom.Common.Messages.Agent.BiDirectional;
|
||||
public sealed partial record ReplyMessage(
|
||||
[property: MemoryPackOrder(0)] uint SequenceId,
|
||||
[property: MemoryPackOrder(1)] byte[] SerializedReply
|
||||
) : IMessageToController, IMessageToAgent, IReply {
|
||||
) : IMessageToController, IMessageToAgent {
|
||||
public Task<NoReply> Accept(IMessageToControllerListener listener) {
|
||||
return listener.HandleReply(this);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ namespace Phantom.Common.Messages.Web.BiDirectional;
|
||||
public sealed partial record ReplyMessage(
|
||||
[property: MemoryPackOrder(0)] uint SequenceId,
|
||||
[property: MemoryPackOrder(1)] byte[] SerializedReply
|
||||
) : IMessageToController, IMessageToWeb, IReply {
|
||||
) : IMessageToController, IMessageToWeb {
|
||||
public Task<NoReply> Accept(IMessageToControllerListener listener) {
|
||||
return listener.HandleReply(this);
|
||||
}
|
||||
|
@ -6,6 +6,6 @@ using Phantom.Utils.Rpc.Message;
|
||||
namespace Phantom.Common.Messages.Web;
|
||||
|
||||
public interface IMessageToControllerListener {
|
||||
Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message);
|
||||
Task<ICreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message);
|
||||
Task<NoReply> HandleReply(ReplyMessage message);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
using MemoryPack;
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
|
||||
namespace Phantom.Common.Messages.Web.ToController;
|
||||
|
||||
@ -7,8 +7,8 @@ namespace Phantom.Common.Messages.Web.ToController;
|
||||
public sealed partial record CreateOrUpdateAdministratorUser(
|
||||
[property: MemoryPackOrder(0)] string Username,
|
||||
[property: MemoryPackOrder(1)] string Password
|
||||
) : IMessageToController<CreateOrUpdateAdministratorUserResult> {
|
||||
public Task<CreateOrUpdateAdministratorUserResult> Accept(IMessageToControllerListener listener) {
|
||||
) : IMessageToController {
|
||||
public Task<NoReply> Accept(IMessageToControllerListener listener) {
|
||||
return listener.CreateOrUpdateAdministratorUser(this);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.Web.BiDirectional;
|
||||
using Phantom.Common.Messages.Web.ToController;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
@ -13,7 +12,7 @@ public static class WebMessageRegistries {
|
||||
public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> Definitions { get; } = new MessageDefinitions();
|
||||
|
||||
static WebMessageRegistries() {
|
||||
ToController.Add<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(1);
|
||||
ToController.Add<CreateOrUpdateAdministratorUser>(1);
|
||||
ToController.Add<ReplyMessage>(127);
|
||||
|
||||
ToWeb.Add<ReplyMessage>(127);
|
||||
|
@ -4,21 +4,17 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
|
||||
namespace Phantom.Controller.Database.Postgres;
|
||||
|
||||
public sealed class ApplicationDbContextFactory : IDbContextProvider {
|
||||
public sealed class ApplicationDbContextFactory : IDatabaseProvider {
|
||||
private readonly PooledDbContextFactory<ApplicationDbContext> factory;
|
||||
|
||||
public ApplicationDbContextFactory(string connectionString) {
|
||||
this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32);
|
||||
}
|
||||
|
||||
public ApplicationDbContext Eager() {
|
||||
public ApplicationDbContext Provide() {
|
||||
return factory.CreateDbContext();
|
||||
}
|
||||
|
||||
public ILazyDbContext Lazy() {
|
||||
return new LazyDbContext(this);
|
||||
}
|
||||
|
||||
private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) {
|
||||
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
|
||||
builder.UseNpgsql(connectionString, ConfigureOptions);
|
||||
|
@ -1,16 +0,0 @@
|
||||
namespace Phantom.Controller.Database.Postgres;
|
||||
|
||||
sealed class LazyDbContext : ILazyDbContext {
|
||||
public ApplicationDbContext Ctx => cachedContext ??= contextFactory.Eager();
|
||||
|
||||
private readonly ApplicationDbContextFactory contextFactory;
|
||||
private ApplicationDbContext? cachedContext;
|
||||
|
||||
internal LazyDbContext(ApplicationDbContextFactory contextFactory) {
|
||||
this.contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() {
|
||||
return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
@ -8,8 +8,8 @@ namespace Phantom.Controller.Database;
|
||||
public static class DatabaseMigrator {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator));
|
||||
|
||||
public static async Task Run(IDbContextProvider dbProvider, CancellationToken cancellationToken) {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
public static async Task Run(IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
Logger.Information("Connecting to database...");
|
||||
|
||||
|
@ -12,10 +12,10 @@ public sealed class UserEntity {
|
||||
public string Name { get; set; }
|
||||
public string PasswordHash { get; set; }
|
||||
|
||||
public UserEntity(Guid userGuid, string name, string passwordHash) {
|
||||
public UserEntity(Guid userGuid, string name) {
|
||||
UserGuid = userGuid;
|
||||
Name = name;
|
||||
PasswordHash = passwordHash;
|
||||
PasswordHash = null!;
|
||||
}
|
||||
|
||||
public UserInfo ToUserInfo() {
|
||||
|
@ -6,7 +6,6 @@ public enum AuditLogEventType {
|
||||
UserLoggedIn,
|
||||
UserLoggedOut,
|
||||
UserCreated,
|
||||
UserPasswordChanged,
|
||||
UserRolesChanged,
|
||||
UserDeleted,
|
||||
InstanceCreated,
|
||||
@ -23,7 +22,6 @@ static class AuditLogEventTypeExtensions {
|
||||
{ AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.UserCreated, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
|
||||
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
|
@ -0,0 +1,5 @@
|
||||
namespace Phantom.Controller.Database;
|
||||
|
||||
public interface IDatabaseProvider {
|
||||
ApplicationDbContext Provide();
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
namespace Phantom.Controller.Database;
|
||||
|
||||
public interface IDbContextProvider {
|
||||
ApplicationDbContext Eager();
|
||||
ILazyDbContext Lazy();
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
namespace Phantom.Controller.Database;
|
||||
|
||||
public interface ILazyDbContext : IAsyncDisposable {
|
||||
ApplicationDbContext Ctx { get; }
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Database.Enums;
|
||||
|
||||
namespace Phantom.Controller.Database.Repositories;
|
||||
|
||||
sealed partial class AuditLogRepository {
|
||||
public void AddUserLoggedInEvent(UserEntity user) {
|
||||
AddItem(AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserLoggedOutEvent(Guid userGuid) {
|
||||
AddItem(AuditLogEventType.UserLoggedOut, userGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserCreatedEvent(UserEntity user) {
|
||||
AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserPasswordChangedEvent(UserEntity user) {
|
||||
AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) {
|
||||
var extra = new Dictionary<string, object?>();
|
||||
|
||||
if (addedToRoles.Count > 0) {
|
||||
extra["addedToRoles"] = addedToRoles;
|
||||
}
|
||||
|
||||
if (removedFromRoles.Count > 0) {
|
||||
extra["removedFromRoles"] = removedFromRoles;
|
||||
}
|
||||
|
||||
AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra);
|
||||
}
|
||||
|
||||
public void AddUserDeletedEvent(UserEntity user) {
|
||||
AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "username", user.Name }
|
||||
});
|
||||
}
|
||||
|
||||
public void AddInstanceCreatedEvent(Guid instanceGuid) {
|
||||
AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddInstanceEditedEvent(Guid instanceGuid) {
|
||||
AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddInstanceLaunchedEvent(Guid instanceGuid) {
|
||||
AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) {
|
||||
AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "command", command }
|
||||
});
|
||||
}
|
||||
|
||||
public void AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) {
|
||||
AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "stop_in_seconds", stopInSeconds.ToString() }
|
||||
});
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Database.Enums;
|
||||
using Phantom.Controller.Services.Audit;
|
||||
|
||||
namespace Phantom.Controller.Database.Repositories;
|
||||
|
||||
public sealed partial class AuditLogRepository {
|
||||
private readonly ILazyDbContext db;
|
||||
private readonly Guid? currentUserGuid;
|
||||
|
||||
public AuditLogRepository(ILazyDbContext db, Guid? currentUserGuid) {
|
||||
this.db = db;
|
||||
this.currentUserGuid = currentUserGuid;
|
||||
}
|
||||
|
||||
private void AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||
db.Ctx.AuditLog.Add(new AuditLogEntity(currentUserGuid, eventType, subjectId, extra));
|
||||
}
|
||||
|
||||
public Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
||||
return db.Ctx
|
||||
.AuditLog
|
||||
.Include(static entity => entity.User)
|
||||
.AsQueryable()
|
||||
.OrderByDescending(static entity => entity.UtcTime)
|
||||
.Take(count)
|
||||
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Services.Users.Roles;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Tasks;
|
||||
|
||||
namespace Phantom.Controller.Services.Users;
|
||||
|
||||
public sealed class RoleRepository {
|
||||
private const int MaxRoleNameLength = 40;
|
||||
|
||||
private readonly ILazyDbContext db;
|
||||
|
||||
public RoleRepository(ILazyDbContext db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public Task<List<RoleEntity>> GetAll() {
|
||||
return db.Ctx.Roles.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<ImmutableHashSet<string>> GetAllNames() {
|
||||
return db.Ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||
}
|
||||
|
||||
public ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
||||
return db.Ctx.Roles.FindAsync(guid);
|
||||
}
|
||||
|
||||
public async Task<Result<RoleEntity, AddRoleError>> Create(string name) {
|
||||
if (string.IsNullOrWhiteSpace(name)) {
|
||||
return AddRoleError.NameIsEmpty;
|
||||
}
|
||||
else if (name.Length > MaxRoleNameLength) {
|
||||
return AddRoleError.NameIsTooLong;
|
||||
}
|
||||
|
||||
if (await db.Ctx.Roles.AnyAsync(role => role.Name == name)) {
|
||||
return AddRoleError.NameAlreadyExists;
|
||||
}
|
||||
|
||||
var role = new RoleEntity(Guid.NewGuid(), name);
|
||||
db.Ctx.Roles.Add(role);
|
||||
return role;
|
||||
}
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Tasks;
|
||||
|
||||
namespace Phantom.Controller.Database.Repositories;
|
||||
|
||||
public sealed class UserRepository {
|
||||
private const int MaxUserNameLength = 40;
|
||||
private const int MinimumPasswordLength = 16;
|
||||
|
||||
private static UsernameRequirementViolation? CheckUsernameRequirements(string username) {
|
||||
if (string.IsNullOrWhiteSpace(username)) {
|
||||
return new UsernameRequirementViolation.IsEmpty();
|
||||
}
|
||||
else if (username.Length > MaxUserNameLength) {
|
||||
return new UsernameRequirementViolation.TooLong(MaxUserNameLength);
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) {
|
||||
var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>();
|
||||
|
||||
if (password.Length < MinimumPasswordLength) {
|
||||
violations.Add(new PasswordRequirementViolation.TooShort(MinimumPasswordLength));
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsLower)) {
|
||||
violations.Add(new PasswordRequirementViolation.LowercaseLetterRequired());
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsUpper)) {
|
||||
violations.Add(new PasswordRequirementViolation.UppercaseLetterRequired());
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsDigit)) {
|
||||
violations.Add(new PasswordRequirementViolation.DigitRequired());
|
||||
}
|
||||
|
||||
return violations.ToImmutable();
|
||||
}
|
||||
|
||||
private readonly ILazyDbContext db;
|
||||
|
||||
private AuditLogRepository? auditLog;
|
||||
private AuditLogRepository AuditLogRepository => this.auditLog ??= new AuditLogRepository(db, null);
|
||||
|
||||
public UserRepository(ILazyDbContext db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<UserEntity>> GetAll() {
|
||||
return db.Ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
||||
}
|
||||
|
||||
public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
||||
return db.Ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserEntity?> GetByGuid(Guid guid) {
|
||||
return await db.Ctx.Users.FindAsync(guid);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByName(string username) {
|
||||
return db.Ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||
}
|
||||
|
||||
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
|
||||
var usernameRequirementViolation = CheckUsernameRequirements(username);
|
||||
if (usernameRequirementViolation != null) {
|
||||
return new AddUserError.NameIsInvalid(usernameRequirementViolation);
|
||||
}
|
||||
|
||||
var passwordRequirementViolations = CheckPasswordRequirements(password);
|
||||
if (!passwordRequirementViolations.IsEmpty) {
|
||||
return new AddUserError.PasswordIsInvalid(passwordRequirementViolations);
|
||||
}
|
||||
|
||||
if (await db.Ctx.Users.AnyAsync(user => user.Name == username)) {
|
||||
return new AddUserError.NameAlreadyExists();
|
||||
}
|
||||
|
||||
var user = new UserEntity(Guid.NewGuid(), username, UserPasswords.Hash(password));
|
||||
|
||||
db.Ctx.Users.Add(user);
|
||||
AuditLogRepository.AddUserCreatedEvent(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public Result<SetUserPasswordError> SetUserPassword(UserEntity user, string password) {
|
||||
var requirementViolations = CheckPasswordRequirements(password);
|
||||
if (!requirementViolations.IsEmpty) {
|
||||
return new SetUserPasswordError.PasswordIsInvalid(requirementViolations);
|
||||
}
|
||||
|
||||
user.PasswordHash = UserPasswords.Hash(password);
|
||||
AuditLogRepository.AddUserPasswordChangedEvent(user);
|
||||
|
||||
return Result.Ok<SetUserPasswordError>();
|
||||
}
|
||||
|
||||
public void DeleteUser(UserEntity user) {
|
||||
db.Ctx.Users.Remove(user);
|
||||
AuditLogRepository.AddUserDeletedEvent(user);
|
||||
}
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Utils.Collections;
|
||||
|
||||
namespace Phantom.Controller.Database.Repositories;
|
||||
|
||||
public sealed class UserRoleRepository {
|
||||
private readonly ILazyDbContext db;
|
||||
|
||||
public UserRoleRepository(ILazyDbContext db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
||||
return db.Ctx.UserRoles
|
||||
.Include(static ur => ur.Role)
|
||||
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
||||
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
||||
return db.Ctx.UserRoles
|
||||
.Include(static ur => ur.Role)
|
||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||
.Select(static ur => ur.Role)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableArrayAsync();
|
||||
}
|
||||
|
||||
public Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
||||
return db.Ctx.UserRoles
|
||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||
.Select(static ur => ur.RoleGuid)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableSetAsync();
|
||||
}
|
||||
|
||||
public async Task Add(UserEntity user, RoleEntity role) {
|
||||
var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||
if (userRole == null) {
|
||||
db.Ctx.UserRoles.Add(new UserRoleEntity(user.UserGuid, role.RoleGuid));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserRoleEntity?> Remove(UserEntity user, RoleEntity role) {
|
||||
var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||
if (userRole == null) {
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
db.Ctx.UserRoles.Remove(userRole);
|
||||
return userRole;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Minecraft;
|
||||
namespace Phantom.Controller.Minecraft;
|
||||
|
||||
public static class JvmArgumentsHelper {
|
||||
public static ImmutableArray<string> Split(string arguments) {
|
||||
@ -37,4 +37,13 @@ public static class JvmArgumentsHelper {
|
||||
XmxNotAllowed,
|
||||
XmsNotAllowed
|
||||
}
|
||||
|
||||
public static string ToSentence(this ValidationError? result) {
|
||||
return result switch {
|
||||
ValidationError.InvalidFormat => "Invalid format.",
|
||||
ValidationError.XmxNotAllowed => "The -Xmx argument must not be specified manually.",
|
||||
ValidationError.XmsNotAllowed => "The -Xms argument must not be specified manually.",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(result), result, null)
|
||||
};
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ using Phantom.Controller.Services.Instances;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Events;
|
||||
using Phantom.Utils.Tasks;
|
||||
using Serilog;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Controller.Services.Agents;
|
||||
|
||||
@ -27,17 +27,17 @@ public sealed class AgentManager {
|
||||
|
||||
private readonly CancellationToken cancellationToken;
|
||||
private readonly AuthToken authToken;
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
|
||||
public AgentManager(AuthToken authToken, IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||
public AgentManager(AuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||
this.authToken = authToken;
|
||||
this.dbProvider = dbProvider;
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.cancellationToken = cancellationToken;
|
||||
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
|
||||
}
|
||||
|
||||
internal async Task Initialize() {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
|
||||
@ -68,7 +68,7 @@ public sealed class AgentManager {
|
||||
oldAgent.Connection?.Close();
|
||||
}
|
||||
|
||||
await using (var ctx = dbProvider.Eager()) {
|
||||
await using (var ctx = databaseProvider.Provide()) {
|
||||
var entity = ctx.AgentUpsert.Fetch(agent.Guid);
|
||||
|
||||
entity.Name = agent.Name;
|
||||
|
@ -0,0 +1,70 @@
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Database.Enums;
|
||||
|
||||
namespace Phantom.Controller.Services.Audit;
|
||||
|
||||
public sealed partial class AuditLog {
|
||||
public Task AddAdministratorUserCreatedEvent(UserEntity administratorUser) {
|
||||
return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddAdministratorUserModifiedEvent(UserEntity administratorUser) {
|
||||
return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserLoggedInEvent(UserEntity user) {
|
||||
AddItem(user.UserGuid, AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public void AddUserLoggedOutEvent(Guid userGuid) {
|
||||
AddItem(userGuid, AuditLogEventType.UserLoggedOut, userGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddUserCreatedEvent(UserEntity user) {
|
||||
return AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) {
|
||||
var extra = new Dictionary<string, object?>();
|
||||
|
||||
if (addedToRoles.Count > 0) {
|
||||
extra["addedToRoles"] = addedToRoles;
|
||||
}
|
||||
|
||||
if (removedFromRoles.Count > 0) {
|
||||
extra["removedFromRoles"] = removedFromRoles;
|
||||
}
|
||||
|
||||
return AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra);
|
||||
}
|
||||
|
||||
public Task AddUserDeletedEvent(UserEntity user) {
|
||||
return AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "username", user.Name }
|
||||
});
|
||||
}
|
||||
|
||||
public Task AddInstanceCreatedEvent(Guid instanceGuid) {
|
||||
return AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddInstanceEditedEvent(Guid instanceGuid) {
|
||||
return AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddInstanceLaunchedEvent(Guid instanceGuid) {
|
||||
return AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString());
|
||||
}
|
||||
|
||||
public Task AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) {
|
||||
return AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "command", command }
|
||||
});
|
||||
}
|
||||
|
||||
public Task AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) {
|
||||
return AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||
{ "stop_in_seconds", stopInSeconds.ToString() }
|
||||
});
|
||||
}
|
||||
}
|
51
Controller/Phantom.Controller.Services/Audit/AuditLog.cs
Normal file
51
Controller/Phantom.Controller.Services/Audit/AuditLog.cs
Normal file
@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Database.Enums;
|
||||
using Phantom.Controller.Services.Users;
|
||||
using Phantom.Utils.Tasks;
|
||||
|
||||
namespace Phantom.Controller.Services.Audit;
|
||||
|
||||
public sealed partial class AuditLog {
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly TaskManager taskManager;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.taskManager = taskManager;
|
||||
this.cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetCurrentAuthenticatedUserId() {
|
||||
var authenticationState = await authenticationStateProvider.GetAuthenticationStateAsync();
|
||||
return UserManager.GetAuthenticatedUserId(authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
ctx.AuditLog.Add(logEntity);
|
||||
await ctx.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||
var logEntity = new AuditLogEntity(userGuid, eventType, subjectId, extra);
|
||||
taskManager.Run("Store audit log item to database", () => AddEntityToDatabase(logEntity));
|
||||
}
|
||||
|
||||
private async Task AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||
AddItem(await GetCurrentAuthenticatedUserId(), eventType, subjectId, extra);
|
||||
}
|
||||
|
||||
public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.AuditLog
|
||||
.Include(static entity => entity.User)
|
||||
.AsQueryable()
|
||||
.OrderByDescending(static entity => entity.UtcTime)
|
||||
.Take(count)
|
||||
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
}
|
@ -6,11 +6,13 @@ using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Minecraft;
|
||||
using Phantom.Controller.Rpc;
|
||||
using Phantom.Controller.Services.Agents;
|
||||
using Phantom.Controller.Services.Audit;
|
||||
using Phantom.Controller.Services.Events;
|
||||
using Phantom.Controller.Services.Instances;
|
||||
using Phantom.Controller.Services.Rpc;
|
||||
using Phantom.Controller.Services.Users;
|
||||
using Phantom.Controller.Services.Users.Permissions;
|
||||
using Phantom.Controller.Services.Users.Roles;
|
||||
using Phantom.Utils.Tasks;
|
||||
|
||||
namespace Phantom.Controller.Services;
|
||||
@ -19,6 +21,8 @@ public sealed class ControllerServices {
|
||||
private TaskManager TaskManager { get; }
|
||||
private MinecraftVersions MinecraftVersions { get; }
|
||||
|
||||
private AuditLog AuditLog { get; }
|
||||
|
||||
private AgentManager AgentManager { get; }
|
||||
private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; }
|
||||
private EventLog EventLog { get; }
|
||||
@ -27,26 +31,30 @@ public sealed class ControllerServices {
|
||||
|
||||
private UserManager UserManager { get; }
|
||||
private RoleManager RoleManager { get; }
|
||||
private UserRoleManager UserRoleManager { get; }
|
||||
private PermissionManager PermissionManager { get; }
|
||||
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) {
|
||||
public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) {
|
||||
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>());
|
||||
this.MinecraftVersions = new MinecraftVersions();
|
||||
|
||||
this.AgentManager = new AgentManager(agentAuthToken, dbProvider, TaskManager, shutdownCancellationToken);
|
||||
this.AuditLog = new AuditLog(databaseProvider, TaskManager, shutdownCancellationToken);
|
||||
|
||||
this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken);
|
||||
this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
|
||||
this.EventLog = new EventLog(dbProvider, TaskManager, shutdownCancellationToken);
|
||||
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, dbProvider, shutdownCancellationToken);
|
||||
this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken);
|
||||
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken);
|
||||
this.InstanceLogManager = new InstanceLogManager();
|
||||
|
||||
this.UserManager = new UserManager(dbProvider);
|
||||
this.RoleManager = new RoleManager(dbProvider);
|
||||
this.PermissionManager = new PermissionManager(dbProvider);
|
||||
this.UserManager = new UserManager(databaseProvider, AuditLog);
|
||||
this.RoleManager = new RoleManager(databaseProvider);
|
||||
this.UserRoleManager = new UserRoleManager(databaseProvider);
|
||||
this.PermissionManager = new PermissionManager(databaseProvider);
|
||||
|
||||
this.dbProvider = dbProvider;
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.cancellationToken = shutdownCancellationToken;
|
||||
}
|
||||
|
||||
@ -55,11 +63,11 @@ public sealed class ControllerServices {
|
||||
}
|
||||
|
||||
public WebMessageListener CreateWebMessageListener(RpcClientConnection<IMessageToWebListener> connection) {
|
||||
return new WebMessageListener(connection, UserManager, RoleManager);
|
||||
return new WebMessageListener(connection, AuditLog, UserManager, RoleManager, UserRoleManager);
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await DatabaseMigrator.Run(dbProvider, cancellationToken);
|
||||
await DatabaseMigrator.Run(databaseProvider, cancellationToken);
|
||||
await PermissionManager.Initialize();
|
||||
await RoleManager.Initialize();
|
||||
await AgentManager.Initialize();
|
||||
|
@ -9,18 +9,18 @@ using Phantom.Utils.Tasks;
|
||||
namespace Phantom.Controller.Services.Events;
|
||||
|
||||
public sealed partial class EventLog {
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly TaskManager taskManager;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
public EventLog(IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||
this.dbProvider = dbProvider;
|
||||
public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.taskManager = taskManager;
|
||||
this.cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
ctx.EventLog.Add(logEntity);
|
||||
await ctx.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@ -31,7 +31,7 @@ public sealed partial class EventLog {
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.EventLog
|
||||
.AsQueryable()
|
||||
.OrderByDescending(static entity => entity.UtcTime)
|
||||
|
@ -4,7 +4,6 @@ using Phantom.Common.Data;
|
||||
using Phantom.Common.Data.Instance;
|
||||
using Phantom.Common.Data.Minecraft;
|
||||
using Phantom.Common.Data.Replies;
|
||||
using Phantom.Common.Data.Web.Minecraft;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.Agent;
|
||||
using Phantom.Common.Messages.Agent.ToAgent;
|
||||
@ -14,11 +13,11 @@ using Phantom.Controller.Minecraft;
|
||||
using Phantom.Controller.Services.Agents;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Events;
|
||||
using Serilog;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Controller.Services.Instances;
|
||||
|
||||
sealed class InstanceManager {
|
||||
public sealed class InstanceManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>();
|
||||
|
||||
private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>());
|
||||
@ -27,20 +26,20 @@ sealed class InstanceManager {
|
||||
|
||||
private readonly AgentManager agentManager;
|
||||
private readonly MinecraftVersions minecraftVersions;
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
|
||||
|
||||
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
|
||||
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
||||
this.agentManager = agentManager;
|
||||
this.minecraftVersions = minecraftVersions;
|
||||
this.dbProvider = dbProvider;
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||
var configuration = new InstanceConfiguration(
|
||||
entity.AgentGuid,
|
||||
@ -99,7 +98,7 @@ sealed class InstanceManager {
|
||||
});
|
||||
|
||||
if (result.Is(AddOrEditInstanceResult.Success)) {
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
||||
|
||||
entity.AgentGuid = configuration.AgentGuid;
|
||||
@ -189,8 +188,8 @@ sealed class InstanceManager {
|
||||
try {
|
||||
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
|
||||
|
||||
await using var ctx = dbProvider.Eager();
|
||||
var entity = await ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken);
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken);
|
||||
if (entity != null) {
|
||||
entity.LaunchAutomatically = shouldLaunchAutomatically;
|
||||
await ctx.SaveChangesAsync(cancellationToken);
|
||||
@ -201,12 +200,7 @@ sealed class InstanceManager {
|
||||
}
|
||||
|
||||
public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
|
||||
var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
|
||||
if (result.Is(SendCommandToInstanceResult.Success)) {
|
||||
// TODO audit log
|
||||
}
|
||||
|
||||
return result;
|
||||
return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
|
||||
}
|
||||
|
||||
internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
|
||||
|
@ -11,15 +11,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
|
||||
<ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
||||
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
|
||||
<ProjectReference Include="..\Phantom.Controller.Rpc\Phantom.Controller.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Audit\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -1,26 +1,64 @@
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using Phantom.Common.Messages.Web;
|
||||
using Phantom.Common.Messages.Web;
|
||||
using Phantom.Common.Messages.Web.BiDirectional;
|
||||
using Phantom.Common.Messages.Web.ToController;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Rpc;
|
||||
using Phantom.Controller.Services.Audit;
|
||||
using Phantom.Controller.Services.Users;
|
||||
using Phantom.Controller.Services.Users.Roles;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
using Phantom.Utils.Tasks;
|
||||
|
||||
namespace Phantom.Controller.Services.Rpc;
|
||||
|
||||
public sealed class WebMessageListener : IMessageToControllerListener {
|
||||
private readonly RpcClientConnection<IMessageToWebListener> connection;
|
||||
private readonly AuditLog auditLog;
|
||||
private readonly UserManager userManager;
|
||||
private readonly RoleManager roleManager;
|
||||
private readonly UserRoleManager userRoleManager;
|
||||
|
||||
internal WebMessageListener(RpcClientConnection<IMessageToWebListener> connection, UserManager userManager, RoleManager roleManager) {
|
||||
internal WebMessageListener(RpcClientConnection<IMessageToWebListener> connection, AuditLog auditLog, UserManager userManager, RoleManager roleManager, UserRoleManager userRoleManager) {
|
||||
this.connection = connection;
|
||||
this.auditLog = auditLog;
|
||||
this.userManager = userManager;
|
||||
this.roleManager = roleManager;
|
||||
this.userRoleManager = userRoleManager;
|
||||
}
|
||||
|
||||
public Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message) {
|
||||
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
|
||||
public async Task<ICreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message) {
|
||||
UserEntity administratorUser = null!;
|
||||
|
||||
var existingUser = await userManager.GetByName(message.Username);
|
||||
if (existingUser == null) {
|
||||
var result = await userManager.CreateUser(message.Username, message.Password);
|
||||
switch (result) {
|
||||
case Result<UserEntity, AddUserError>.Ok ok:
|
||||
administratorUser = ok.Value;
|
||||
await auditLog.AddAdministratorUserCreatedEvent(administratorUser);
|
||||
break;
|
||||
|
||||
case Result<UserEntity, AddUserError>.Fail fail:
|
||||
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(fail.Error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
var result = await userManager.SetUserPassword(existingUser.UserGuid, message.Password);
|
||||
if (result is Result<SetUserPasswordError>.Fail fail) {
|
||||
return new ICreateOrUpdateAdministratorUserResult.UpdatingFailed(fail.Error);
|
||||
}
|
||||
else {
|
||||
administratorUser = existingUser;
|
||||
await auditLog.AddAdministratorUserModifiedEvent(administratorUser);
|
||||
}
|
||||
}
|
||||
|
||||
var administratorRole = await roleManager.GetByGuid(Role.Administrator.Guid);
|
||||
if (administratorRole == null || !await userRoleManager.Add(administratorUser, administratorRole)) {
|
||||
return new ICreateOrUpdateAdministratorUserResult.AddingToRoleFailed();
|
||||
}
|
||||
|
||||
return new ICreateOrUpdateAdministratorUserResult.Success(administratorUser.ToUserInfo());
|
||||
}
|
||||
|
||||
public Task<NoReply> HandleReply(ReplyMessage message) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Phantom.Common.Data.Web.Users;
|
||||
namespace Phantom.Controller.Services.Users;
|
||||
|
||||
public enum DeleteUserResult : byte {
|
||||
Deleted,
|
@ -1,14 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Phantom.Common.Data.Web.Users.Permissions;
|
||||
namespace Phantom.Controller.Services.Users.Permissions;
|
||||
|
||||
public sealed class IdentityPermissions {
|
||||
public static IdentityPermissions None { get; } = new (ImmutableHashSet<string>.Empty);
|
||||
internal static IdentityPermissions None { get; } = new ();
|
||||
|
||||
private readonly ImmutableHashSet<string> permissionIds;
|
||||
|
||||
public IdentityPermissions(ImmutableHashSet<string> permissionIdsQuery) {
|
||||
this.permissionIds = permissionIdsQuery;
|
||||
internal IdentityPermissions(IQueryable<string> permissionIdsQuery) {
|
||||
this.permissionIds = permissionIdsQuery.ToImmutableHashSet();
|
||||
}
|
||||
|
||||
private IdentityPermissions() {
|
||||
this.permissionIds = ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
public bool Check(Permission? permission) {
|
@ -1,7 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Data.Web.Users.Permissions;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
@ -13,17 +12,17 @@ namespace Phantom.Controller.Services.Users.Permissions;
|
||||
public sealed class PermissionManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
|
||||
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
|
||||
|
||||
public PermissionManager(IDbContextProvider dbProvider) {
|
||||
this.dbProvider = dbProvider;
|
||||
public PermissionManager(IDatabaseProvider databaseProvider) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
internal async Task Initialize() {
|
||||
Logger.Information("Adding default permissions to database.");
|
||||
|
||||
await using var ctx = dbProvider.Eager();
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
||||
@ -43,10 +42,10 @@ public sealed class PermissionManager {
|
||||
}
|
||||
|
||||
private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
|
||||
using var ctx = dbProvider.Lazy();
|
||||
using var ctx = databaseProvider.Provide();
|
||||
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 IdentityPermissions(userPermissions.Union(rolePermissions).ToImmutableHashSet());
|
||||
return new IdentityPermissions(userPermissions.Union(rolePermissions));
|
||||
}
|
||||
|
||||
private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
|
@ -1,54 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Services.Users.Permissions;
|
||||
using Phantom.Controller.Services.Users.Roles;
|
||||
using Phantom.Utils.Collections;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Controller.Services.Users;
|
||||
|
||||
sealed class RoleManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
||||
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
|
||||
public RoleManager(IDbContextProvider dbProvider) {
|
||||
this.dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
internal async Task Initialize() {
|
||||
Logger.Information("Adding default roles to database.");
|
||||
|
||||
await using var ctx = dbProvider.Eager();
|
||||
|
||||
var existingRoleNames = await ctx.Roles
|
||||
.Select(static role => role.Name)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableSetAsync();
|
||||
|
||||
var existingPermissionIdsByRoleGuid = await ctx.RolePermissions
|
||||
.GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId)
|
||||
.ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet());
|
||||
|
||||
foreach (var role in Role.All) {
|
||||
if (!existingRoleNames.Contains(role.Name)) {
|
||||
Logger.Information("Adding default role \"{Name}\".", role.Name);
|
||||
ctx.Roles.Add(new RoleEntity(role.Guid, role.Name));
|
||||
}
|
||||
|
||||
var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty;
|
||||
var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
||||
if (!missingPermissionIds.IsEmpty) {
|
||||
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
||||
foreach (var permissionId in missingPermissionIds) {
|
||||
ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using Phantom.Common.Data.Web.Users.Permissions;
|
||||
|
||||
namespace Phantom.Controller.Services.Users.Roles;
|
||||
|
@ -0,0 +1,99 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Services.Users.Permissions;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Tasks;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Controller.Services.Users.Roles;
|
||||
|
||||
public sealed class RoleManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
||||
|
||||
private const int MaxRoleNameLength = 40;
|
||||
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
|
||||
public RoleManager(IDatabaseProvider databaseProvider) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
internal async Task Initialize() {
|
||||
Logger.Information("Adding default roles to database.");
|
||||
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
var existingRoleNames = await ctx.Roles
|
||||
.Select(static role => role.Name)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableSetAsync();
|
||||
|
||||
var existingPermissionIdsByRoleGuid = await ctx.RolePermissions
|
||||
.GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId)
|
||||
.ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet());
|
||||
|
||||
foreach (var role in Role.All) {
|
||||
if (!existingRoleNames.Contains(role.Name)) {
|
||||
Logger.Information("Adding default role \"{Name}\".", role.Name);
|
||||
ctx.Roles.Add(new RoleEntity(role.Guid, role.Name));
|
||||
}
|
||||
|
||||
var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty;
|
||||
var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
||||
if (!missingPermissionIds.IsEmpty) {
|
||||
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
||||
foreach (var permissionId in missingPermissionIds) {
|
||||
ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<RoleEntity>> GetAll() {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Roles.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ImmutableHashSet<string>> GetAllNames() {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||
}
|
||||
|
||||
public async ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Roles.FindAsync(guid);
|
||||
}
|
||||
|
||||
public async Task<Result<RoleEntity, AddRoleError>> Create(string name) {
|
||||
if (string.IsNullOrWhiteSpace(name)) {
|
||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty);
|
||||
}
|
||||
else if (name.Length > MaxRoleNameLength) {
|
||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong);
|
||||
}
|
||||
|
||||
RoleEntity newRole;
|
||||
try {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
if (await ctx.Roles.AnyAsync(role => role.Name == name)) {
|
||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists);
|
||||
}
|
||||
|
||||
newRole = new RoleEntity(Guid.NewGuid(), name);
|
||||
ctx.Roles.Add(newRole);
|
||||
await ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not create role \"{Name}\".", name);
|
||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError);
|
||||
}
|
||||
|
||||
Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, newRole.RoleGuid);
|
||||
return Result.Ok<RoleEntity, AddRoleError>(newRole);
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Utils.Collections;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Controller.Services.Users.Roles;
|
||||
|
||||
public sealed class UserRoleManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
|
||||
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
|
||||
public UserRoleManager(IDatabaseProvider databaseProvider) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.UserRoles
|
||||
.Include(static ur => ur.Role)
|
||||
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
||||
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.UserRoles
|
||||
.Include(static ur => ur.Role)
|
||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||
.Select(static ur => ur.Role)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.UserRoles
|
||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||
.Select(static ur => ur.RoleGuid)
|
||||
.AsAsyncEnumerable()
|
||||
.ToImmutableSetAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> Add(UserEntity user, RoleEntity role) {
|
||||
try {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||
if (userRole != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid);
|
||||
ctx.UserRoles.Add(userRole);
|
||||
await ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> Remove(UserEntity user, RoleEntity role) {
|
||||
try {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
|
||||
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||
if (userRole == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.UserRoles.Remove(userRole);
|
||||
await ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Controller.Database;
|
||||
using Phantom.Controller.Database.Repositories;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
using Phantom.Controller.Services.Audit;
|
||||
using Phantom.Controller.Services.Users.Roles;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Controller.Services.Users;
|
||||
@ -10,108 +15,213 @@ namespace Phantom.Controller.Services.Users;
|
||||
sealed class UserManager {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
|
||||
|
||||
private readonly IDbContextProvider dbProvider;
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private readonly AuditLog auditLog;
|
||||
|
||||
public UserManager(IDbContextProvider dbProvider) {
|
||||
this.dbProvider = dbProvider;
|
||||
public UserManager(IDatabaseProvider databaseProvider, AuditLog auditLog) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
this.auditLog = auditLog;
|
||||
}
|
||||
|
||||
// public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
|
||||
// if (user.Identity is not { IsAuthenticated: true }) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// var claim = user.FindFirst(ClaimTypes.NameIdentifier);
|
||||
// if (claim == null) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// return Guid.TryParse(claim.Value, out var guid) ? guid : null;
|
||||
// }
|
||||
//
|
||||
// public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
||||
// await using var ctx = dbProvider.Lazy();
|
||||
// var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||
// if (user == null) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// switch (UserValidation.VerifyPassword(user, password)) {
|
||||
// case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
// try {
|
||||
// UserValidation.SetPassword(user, password);
|
||||
// await ctx.SaveChangesAsync();
|
||||
// } catch (Exception e) {
|
||||
// Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
|
||||
// }
|
||||
//
|
||||
// goto case PasswordVerificationResult.Success;
|
||||
//
|
||||
// case PasswordVerificationResult.Success:
|
||||
// return user;
|
||||
//
|
||||
// case PasswordVerificationResult.Failed:
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// throw new InvalidOperationException();
|
||||
// }
|
||||
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
|
||||
if (user.Identity is not { IsAuthenticated: true }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministrator(string username, string password) {
|
||||
await using var db = dbProvider.Lazy();
|
||||
var repository = new UserRepository(db);
|
||||
var claim = user.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
var user = await repository.GetByName(username);
|
||||
if (user == null) {
|
||||
var result = await repository.CreateUser(username, password);
|
||||
if (result) {
|
||||
user = result.Value;
|
||||
}
|
||||
else {
|
||||
return new CreateOrUpdateAdministratorUserResult.CreationFailed(result.Error);
|
||||
return Guid.TryParse(claim.Value, out var guid) ? guid : null;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<UserEntity>> GetAll() {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserEntity?> GetByName(string username) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||
}
|
||||
|
||||
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
||||
await using var ctx = databaseProvider.Provide();
|
||||
var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (UserValidation.VerifyPassword(user, password)) {
|
||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
try {
|
||||
UserValidation.SetPassword(user, password);
|
||||
await ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
|
||||
}
|
||||
|
||||
goto case PasswordVerificationResult.Success;
|
||||
|
||||
case PasswordVerificationResult.Success:
|
||||
return user;
|
||||
|
||||
case PasswordVerificationResult.Failed:
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public async Task<ICreateOrUpdateAdministratorUserResult> CreateAdministratorUser(string username, string password) {
|
||||
await using var editor = new Editor(databaseProvider);
|
||||
|
||||
var createUserResult = await editor.CreateUser(username, password);
|
||||
if (!createUserResult) {
|
||||
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(createUserResult.Error);
|
||||
}
|
||||
|
||||
UserEntity administratorUser = null!;
|
||||
|
||||
var existingUser = await userManager.GetByName(message.Username);
|
||||
if (existingUser == null) {
|
||||
var result = await userManager.CreateUser(message.Username, message.Password);
|
||||
switch (result) {
|
||||
case Result<UserEntity, AddUserError>.Ok ok:
|
||||
administratorUser = ok.Value;
|
||||
await auditLog.AddAdministratorUserCreatedEvent(administratorUser);
|
||||
break;
|
||||
|
||||
case Result<UserEntity, AddUserError>.Fail fail:
|
||||
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(fail.Error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
var result = await userManager.SetUserPassword(existingUser.UserGuid, message.Password);
|
||||
if (result is Result<SetUserPasswordError>.Fail fail) {
|
||||
return new ICreateOrUpdateAdministratorUserResult.UpdatingFailed(fail.Error);
|
||||
}
|
||||
else {
|
||||
var result = repository.SetUserPassword(user, password);
|
||||
if (!result) {
|
||||
return new CreateOrUpdateAdministratorUserResult.UpdatingFailed(result.Error);
|
||||
}
|
||||
administratorUser = existingUser;
|
||||
await auditLog.AddAdministratorUserModifiedEvent(administratorUser);
|
||||
}
|
||||
|
||||
var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid);
|
||||
if (role == null) {
|
||||
return new CreateOrUpdateAdministratorUserResult.AddingToRoleFailed();
|
||||
}
|
||||
|
||||
await new UserRoleRepository(db).Add(user, role);
|
||||
|
||||
Logger.Information("Created administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
|
||||
return new CreateOrUpdateAdministratorUserResult.Success(user.ToUserInfo());
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username);
|
||||
return new CreateOrUpdateAdministratorUserResult.UnknownError();
|
||||
}
|
||||
|
||||
var administratorRole = await roleManager.GetByGuid(Role.Administrator.Guid);
|
||||
if (administratorRole == null || !await userRoleManager.Add(administratorUser, administratorRole)) {
|
||||
return new ICreateOrUpdateAdministratorUserResult.AddingToRoleFailed();
|
||||
}
|
||||
|
||||
return new ICreateOrUpdateAdministratorUserResult.Success(administratorUser.ToUserInfo());
|
||||
}
|
||||
|
||||
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
|
||||
await using var editor = new Editor(databaseProvider);
|
||||
return await editor.CreateUser(username, password);
|
||||
}
|
||||
|
||||
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
|
||||
UserEntity foundUser;
|
||||
|
||||
await using (var ctx = databaseProvider.Provide()) {
|
||||
var user = await ctx.Users.FindAsync(guid);
|
||||
if (user == null) {
|
||||
return new SetUserPasswordError.UserNotFound();
|
||||
}
|
||||
|
||||
foundUser = user;
|
||||
try {
|
||||
var requirementViolations = UserValidation.CheckPasswordRequirements(password);
|
||||
if (!requirementViolations.IsEmpty) {
|
||||
return new SetUserPasswordError.PasswordIsInvalid(requirementViolations);
|
||||
}
|
||||
|
||||
UserValidation.SetPassword(user, password);
|
||||
await ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return new SetUserPasswordError.UnknownError();
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid);
|
||||
return Result.Ok<SetUserPasswordError>();
|
||||
}
|
||||
|
||||
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
|
||||
await using var db = dbProvider.Lazy();
|
||||
var repository = new UserRepository(db);
|
||||
|
||||
var user = await repository.GetByGuid(guid);
|
||||
if (user == null) {
|
||||
return DeleteUserResult.NotFound;
|
||||
await using var editor = new Editor(databaseProvider);
|
||||
return await editor.DeleteUserByGuid(guid);
|
||||
}
|
||||
|
||||
private sealed class Editor : IAsyncDisposable {
|
||||
public ApplicationDbContext Ctx => cachedContext ??= databaseProvider.Provide();
|
||||
|
||||
private readonly IDatabaseProvider databaseProvider;
|
||||
private ApplicationDbContext? cachedContext;
|
||||
|
||||
public Editor(IDatabaseProvider databaseProvider) {
|
||||
this.databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
try {
|
||||
repository.DeleteUser(user);
|
||||
await db.Ctx.SaveChangesAsync();
|
||||
|
||||
Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return DeleteUserResult.Deleted;
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not delete user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return DeleteUserResult.Failed;
|
||||
|
||||
public ValueTask DisposeAsync() {
|
||||
return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByName(string username) {
|
||||
return Ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||
}
|
||||
|
||||
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
|
||||
var usernameRequirementViolation = UserValidation.CheckUsernameRequirements(username);
|
||||
if (usernameRequirementViolation != null) {
|
||||
return new AddUserError.NameIsInvalid(usernameRequirementViolation);
|
||||
}
|
||||
|
||||
var passwordRequirementViolations = UserValidation.CheckPasswordRequirements(password);
|
||||
if (!passwordRequirementViolations.IsEmpty) {
|
||||
return new AddUserError.PasswordIsInvalid(passwordRequirementViolations);
|
||||
}
|
||||
|
||||
UserEntity newUser;
|
||||
try {
|
||||
if (await Ctx.Users.AnyAsync(user => user.Name == username)) {
|
||||
return new AddUserError.NameAlreadyExists();
|
||||
}
|
||||
|
||||
newUser = new UserEntity(Guid.NewGuid(), username);
|
||||
UserValidation.SetPassword(newUser, password);
|
||||
|
||||
Ctx.Users.Add(newUser);
|
||||
await Ctx.SaveChangesAsync();
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not create user \"{Name}\".", username);
|
||||
return new AddUserError.UnknownError();
|
||||
}
|
||||
|
||||
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
public async Task<DeleteUserResult> DeleteUserByGuid(Guid guid) {
|
||||
var user = await Ctx.Users.FindAsync(guid);
|
||||
if (user == null) {
|
||||
return DeleteUserResult.NotFound;
|
||||
}
|
||||
|
||||
try {
|
||||
Ctx.Users.Remove(user);
|
||||
await Ctx.SaveChangesAsync();
|
||||
return DeleteUserResult.Deleted;
|
||||
} catch (Exception e) {
|
||||
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||
return DeleteUserResult.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,53 @@
|
||||
using System.Collections.Immutable;
|
||||
using Phantom.Controller.Database.Entities;
|
||||
|
||||
namespace Phantom.Controller.Services.Users;
|
||||
|
||||
static class UserValidation {
|
||||
private static PasswordHasher<UserEntity> Hasher { get; } = new ();
|
||||
|
||||
private const int MaxUserNameLength = 40;
|
||||
private const int MinimumPasswordLength = 16;
|
||||
|
||||
public static UsernameRequirementViolation? CheckUsernameRequirements(string username) {
|
||||
if (string.IsNullOrWhiteSpace(username)) {
|
||||
return new UsernameRequirementViolation.IsEmpty();
|
||||
}
|
||||
else if (username.Length > MaxUserNameLength) {
|
||||
return new UsernameRequirementViolation.TooLong(MaxUserNameLength);
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) {
|
||||
var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>();
|
||||
|
||||
if (password.Length < MinimumPasswordLength) {
|
||||
violations.Add(new PasswordRequirementViolation.TooShort(MinimumPasswordLength));
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsLower)) {
|
||||
violations.Add(new PasswordRequirementViolation.LowercaseLetterRequired());
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsUpper)) {
|
||||
violations.Add(new PasswordRequirementViolation.UppercaseLetterRequired());
|
||||
}
|
||||
|
||||
if (!password.Any(char.IsDigit)) {
|
||||
violations.Add(new PasswordRequirementViolation.DigitRequired());
|
||||
}
|
||||
|
||||
return violations.ToImmutable();
|
||||
}
|
||||
|
||||
public static void SetPassword(UserEntity user, string password) {
|
||||
user.PasswordHash = Hasher.HashPassword(user, password);
|
||||
}
|
||||
|
||||
public static PasswordVerificationResult VerifyPassword(UserEntity user, string password) {
|
||||
return Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
}
|
||||
}
|
@ -12,10 +12,6 @@
|
||||
<PackageReference Update="Kajabity.Tools.Java" Version="0.3.8607.38728" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="BCrypt.Net-Next.StrongName" Version="4.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="MemoryPack" Version="1.9.16" />
|
||||
<PackageReference Update="NetMQ" Version="4.0.1.13" />
|
||||
|
@ -1,6 +0,0 @@
|
||||
namespace Phantom.Utils.Rpc.Message;
|
||||
|
||||
public interface IReply {
|
||||
uint SequenceId { get; }
|
||||
byte[] SerializedReply { get; }
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
|
||||
namespace Phantom.Utils.Rpc;
|
||||
|
||||
public sealed class RpcConnectionToServer<TListener> {
|
||||
private readonly ClientSocket socket;
|
||||
private readonly MessageRegistry<TListener> messageRegistry;
|
||||
private readonly MessageReplyTracker replyTracker;
|
||||
|
||||
public RpcConnectionToServer(ClientSocket socket, MessageRegistry<TListener> messageRegistry, MessageReplyTracker replyTracker) {
|
||||
this.socket = socket;
|
||||
this.messageRegistry = messageRegistry;
|
||||
this.replyTracker = replyTracker;
|
||||
}
|
||||
|
||||
public async Task Send<TMessage>(TMessage message) where TMessage : IMessage<TListener, NoReply> {
|
||||
var bytes = messageRegistry.Write(message).ToArray();
|
||||
if (bytes.Length > 0) {
|
||||
await socket.SendAsync(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
|
||||
var sequenceId = replyTracker.RegisterReply();
|
||||
|
||||
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
|
||||
if (bytes.Length == 0) {
|
||||
replyTracker.ForgetReply(sequenceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await socket.SendAsync(bytes);
|
||||
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
||||
}
|
||||
|
||||
public void Receive(IReply message) {
|
||||
replyTracker.ReceiveReply(message.SequenceId, message.SerializedReply);
|
||||
}
|
||||
}
|
@ -17,6 +17,34 @@ public static class Files {
|
||||
await stream.WriteAsync(bytes);
|
||||
}
|
||||
|
||||
public static async Task ReadExactlyBytesAsync(string path, Memory<byte> bytes) {
|
||||
var options = new FileStreamOptions {
|
||||
Mode = FileMode.Open,
|
||||
Access = FileAccess.Read,
|
||||
Options = FileOptions.Asynchronous,
|
||||
Share = FileShare.Read
|
||||
};
|
||||
|
||||
await using var stream = new FileStream(path, options);
|
||||
|
||||
bool wrongLength = false;
|
||||
|
||||
if (stream.Length == bytes.Length) {
|
||||
try {
|
||||
await stream.ReadExactlyAsync(bytes);
|
||||
} catch (EndOfStreamException) {
|
||||
wrongLength = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
wrongLength = true;
|
||||
}
|
||||
|
||||
if (wrongLength) {
|
||||
throw new IOException("Expected file size to be exactly " + bytes.Length + " B, actual size is " + stream.Length + " B.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireMaximumFileSize(string path, long maximumBytes) {
|
||||
var actualBytes = new FileInfo(path).Length;
|
||||
if (actualBytes > maximumBytes) {
|
||||
|
@ -35,24 +35,13 @@ public abstract record Result<TValue, TError> {
|
||||
|
||||
public abstract record Result<TError> {
|
||||
private Result() {}
|
||||
|
||||
public abstract TError Error { get; init; }
|
||||
|
||||
public static implicit operator Result<TError>(TError error) {
|
||||
return new Fail(error);
|
||||
}
|
||||
|
||||
public static implicit operator bool(Result<TError> result) {
|
||||
return result is Ok;
|
||||
}
|
||||
|
||||
public sealed record Ok : Result<TError> {
|
||||
internal static Ok Instance { get; } = new ();
|
||||
|
||||
public override TError Error {
|
||||
get => throw new InvalidOperationException("Attempted to get error from Ok result.");
|
||||
init {}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record Fail(TError Error) : Result<TError>;
|
||||
|
@ -1,7 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Phantom.Web.Identity.Authentication;
|
||||
using Phantom.Web.Services;
|
||||
using Phantom.Web.Identity.Interfaces;
|
||||
|
||||
namespace Phantom.Web.Identity;
|
||||
|
||||
|
@ -1,10 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Utils.Cryptography;
|
||||
using Phantom.Web.Services;
|
||||
using Phantom.Web.Services.Authentication;
|
||||
using Phantom.Web.Identity.Interfaces;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Web.Identity.Authentication;
|
||||
|
@ -1,10 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Utils.Tasks;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Web.Services.Authentication;
|
||||
namespace Phantom.Web.Identity.Authentication;
|
||||
|
||||
public sealed class PhantomLoginStore {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>();
|
||||
|
@ -3,12 +3,8 @@ using Phantom.Common.Data.Web.Users.Permissions;
|
||||
|
||||
namespace Phantom.Web.Services.Authorization;
|
||||
|
||||
// TODO
|
||||
public class PermissionManager {
|
||||
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
|
||||
|
||||
}
|
||||
|
||||
// TODO
|
||||
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1,20 +0,0 @@
|
||||
using Phantom.Common.Messages.Web;
|
||||
using Phantom.Utils.Rpc;
|
||||
|
||||
namespace Phantom.Web.Services;
|
||||
|
||||
public sealed class ControllerCommunication {
|
||||
private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
|
||||
|
||||
public ControllerCommunication(RpcConnectionToServer<IMessageToControllerListener> connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
|
||||
return connection.Send(message);
|
||||
}
|
||||
|
||||
public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan timeout) where TMessage : IMessageToController<TReply> where TReply : class {
|
||||
return connection.Send<TMessage, TReply>(message, timeout, CancellationToken.None);
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Phantom.Web.Services;
|
||||
namespace Phantom.Web.Identity.Interfaces;
|
||||
|
||||
public interface INavigation {
|
||||
string BasePath { get; }
|
||||
|
@ -1,10 +0,0 @@
|
||||
using Phantom.Common.Data.Replies;
|
||||
|
||||
namespace Phantom.Web.Services.Instances;
|
||||
|
||||
// TODO
|
||||
public class InstanceManager {
|
||||
public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
|
||||
|
||||
}
|
||||
}
|
@ -8,8 +8,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -6,7 +6,6 @@ using Phantom.Common.Data.Web.Users.Permissions;
|
||||
using Phantom.Web.Identity;
|
||||
using Phantom.Web.Identity.Authentication;
|
||||
using Phantom.Web.Identity.Authorization;
|
||||
using Phantom.Web.Services.Authentication;
|
||||
using Phantom.Web.Services.Authorization;
|
||||
|
||||
namespace Phantom.Web.Services;
|
||||
|
@ -1,4 +1,4 @@
|
||||
@using Phantom.Web.Services
|
||||
@using Phantom.Web.Identity.Interfaces
|
||||
@using Phantom.Web.Identity.Authentication
|
||||
@inject INavigation Nav
|
||||
@inject NavigationManager NavigationManager
|
||||
|
17
Web/Phantom.Web/Base/LoginEvents.cs
Normal file
17
Web/Phantom.Web/Base/LoginEvents.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace Phantom.Web.Base;
|
||||
|
||||
sealed class LoginEvents : ILoginEvents {
|
||||
private readonly AuditLog auditLog;
|
||||
|
||||
public LoginEvents(AuditLog auditLog) {
|
||||
this.auditLog = auditLog;
|
||||
}
|
||||
|
||||
public void UserLoggedIn(UserEntity user) {
|
||||
auditLog.AddUserLoggedInEvent(user);
|
||||
}
|
||||
|
||||
public void UserLoggedOut(Guid userGuid) {
|
||||
auditLog.AddUserLoggedOutEvent(userGuid);
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Web;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Phantom.Web.Services;
|
||||
using Phantom.Web.Identity.Interfaces;
|
||||
|
||||
namespace Phantom.Web.Base;
|
||||
|
||||
|
@ -2,7 +2,6 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Phantom.Common.Data.Web.Users.Permissions;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Web.Services.Authorization;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Web.Base;
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Phantom.Utils.Tasks;
|
||||
using Phantom.Web.Base;
|
||||
using Phantom.Web.Identity.Interfaces;
|
||||
using Phantom.Web.Services;
|
||||
using Serilog;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
@using Phantom.Web.Services.Authorization
|
||||
@using Phantom.Common.Data.Web.Users.Permissions
|
||||
@using Phantom.Common.Data.Web.Users.Permissions
|
||||
@inject ServiceConfiguration Configuration
|
||||
@inject PermissionManager PermissionManager
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
@page
|
||||
@using Phantom.Web.Services
|
||||
@using Phantom.Web.Identity.Interfaces
|
||||
@model Phantom.Web.Layout.ErrorModel
|
||||
@inject INavigation Navigation
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@using Phantom.Web.Services
|
||||
@using Phantom.Web.Identity.Interfaces
|
||||
@namespace Phantom.Web.Layout
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject INavigation Navigation
|
||||
|
@ -74,7 +74,7 @@ else {
|
||||
await AuditLog.AddInstanceLaunchedEvent(InstanceGuid);
|
||||
}
|
||||
else {
|
||||
lastError = result.ToSentence(Messages.ToSentence);
|
||||
lastError = result.ToSentence(LaunchInstanceResultExtensions.ToSentence);
|
||||
}
|
||||
} finally {
|
||||
isLaunchingInstance = false;
|
||||
|
@ -1,5 +1,5 @@
|
||||
@page "/login"
|
||||
@using Phantom.Web.Services
|
||||
@using Phantom.Web.Identity.Interfaces
|
||||
@using Phantom.Web.Identity.Authentication
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@attribute [AllowAnonymous]
|
||||
|
@ -1,16 +1,19 @@
|
||||
@page "/setup"
|
||||
@using Phantom.Common.Data.Web.Users
|
||||
@using Phantom.Utils.Cryptography
|
||||
@using Phantom.Utils.Tasks
|
||||
@using Phantom.Web.Identity.Authentication
|
||||
@using Phantom.Web.Services
|
||||
@using Phantom.Web.Services.Users
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Phantom.Utils.Cryptography
|
||||
@using System.Security.Cryptography
|
||||
@using Phantom.Common.Messages.Web.ToController
|
||||
@using Phantom.Common.Data.Web.Users
|
||||
@attribute [AllowAnonymous]
|
||||
@inject ServiceConfiguration ServiceConfiguration
|
||||
@inject PhantomLoginManager LoginManager
|
||||
@inject ControllerCommunication ControllerCommunication
|
||||
@inject UserManager UserManager
|
||||
@inject RoleManager<> RoleManager
|
||||
@inject UserRoleManager UserRoleManager
|
||||
@inject AuditLog AuditLog
|
||||
|
||||
<h1>Administrator Setup</h1>
|
||||
|
||||
@ -86,15 +89,45 @@
|
||||
}
|
||||
|
||||
private async Task<Result<string>> CreateOrUpdateAdministrator() {
|
||||
var reply = await ControllerCommunication.Send<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUser(form.Username, form.Password), Timeout.InfiniteTimeSpan);
|
||||
return reply switch {
|
||||
CreateOrUpdateAdministratorUserResult.Success => Result.Ok<string>(),
|
||||
CreateOrUpdateAdministratorUserResult.CreationFailed fail => fail.Error.ToSentences("\n"),
|
||||
CreateOrUpdateAdministratorUserResult.UpdatingFailed fail => fail.Error.ToSentences("\n"),
|
||||
CreateOrUpdateAdministratorUserResult.AddingToRoleFailed => "Could not assign administrator role to user.",
|
||||
null => "Timed out.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
var existingUser = await UserManager.GetByName(form.Username);
|
||||
return existingUser == null ? await CreateAdministrator() : await UpdateAdministrator(existingUser);
|
||||
}
|
||||
|
||||
private async Task<Result<string>> CreateAdministrator() {
|
||||
var administratorRole = await RoleManager.GetByGuid(Role.Administrator.Guid);
|
||||
if (administratorRole == null) {
|
||||
return Result.Fail("Administrator role not found.");
|
||||
}
|
||||
|
||||
switch (await UserManager.CreateUser(form.Username, form.Password)) {
|
||||
case Result<UserInfo, AddUserError>.Ok ok:
|
||||
var administratorUser = ok.Value;
|
||||
await AuditLog.AddAdministratorUserCreatedEvent(administratorUser);
|
||||
|
||||
if (!await UserRoleManager.Add(administratorUser, administratorRole)) {
|
||||
return Result.Fail("Could not assign administrator role to user.");
|
||||
}
|
||||
|
||||
return Result.Ok<string>();
|
||||
|
||||
case Result<UserInfo, AddUserError>.Fail fail:
|
||||
return Result.Fail(fail.Error.ToSentences("\n"));
|
||||
}
|
||||
|
||||
return Result.Fail("Unknown error.");
|
||||
}
|
||||
|
||||
private async Task<Result<string>> UpdateAdministrator(UserInfo existingUser) {
|
||||
switch (await UserManager.SetUserPassword(existingUser.Guid, form.Password)) {
|
||||
case Result<SetUserPasswordError>.Ok:
|
||||
await AuditLog.AddAdministratorUserModifiedEvent(existingUser);
|
||||
return Result.Ok<string>();
|
||||
|
||||
case Result<SetUserPasswordError>.Fail fail:
|
||||
return Result.Fail(fail.Error.ToSentences("\n"));
|
||||
}
|
||||
|
||||
return Result.Fail("Unknown error.");
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -20,6 +20,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
|
||||
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
|
||||
<ProjectReference Include="..\Phantom.Web.Services\Phantom.Web.Services.csproj" />
|
||||
|
@ -1,12 +1,16 @@
|
||||
@using Phantom.Web.Components.Utils
|
||||
@using Phantom.Common.Data.Minecraft
|
||||
@using Phantom.Common.Data.Web.Minecraft
|
||||
@using Phantom.Common.Data.Instance
|
||||
@using Phantom.Common.Data.Java
|
||||
@using System.Collections.Immutable
|
||||
@using System.Collections.Immutable
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using Phantom.Controller.Minecraft
|
||||
@using Phantom.Controller.Services.Agents
|
||||
@using Phantom.Controller.Services.Audit
|
||||
@using Phantom.Controller.Services.Instances
|
||||
@using Phantom.Web.Components.Utils
|
||||
@using Phantom.Common.Data.Minecraft
|
||||
@using Phantom.Common.Data.Instance
|
||||
@using Phantom.Common.Data.Java
|
||||
@using Phantom.Common.Data
|
||||
@using Phantom.Web.Identity.Interfaces
|
||||
@inject INavigation Nav
|
||||
@inject MinecraftVersions MinecraftVersions
|
||||
@inject AgentManager AgentManager
|
||||
|
@ -1,8 +1,8 @@
|
||||
@using Phantom.Web.Services.Instances
|
||||
@using Phantom.Common.Data.Web.Users.Permissions
|
||||
@using Phantom.Common.Data.Web.Users.Permissions
|
||||
@using Phantom.Common.Data.Replies
|
||||
@inherits PhantomComponent
|
||||
@inject InstanceManager InstanceManager
|
||||
@inject AuditLog AuditLog
|
||||
|
||||
<Form Model="form" OnSubmit="ExecuteCommand">
|
||||
<label for="command-input" class="form-label">Instance Name</label>
|
||||
@ -40,11 +40,12 @@
|
||||
|
||||
var result = await InstanceManager.SendCommand(InstanceGuid, form.Command);
|
||||
if (result.Is(SendCommandToInstanceResult.Success)) {
|
||||
await AuditLog.AddInstanceCommandExecutedEvent(InstanceGuid, form.Command);
|
||||
form.Command = string.Empty;
|
||||
form.SubmitModel.StopSubmitting();
|
||||
}
|
||||
else {
|
||||
form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence));
|
||||
form.SubmitModel.StopSubmitting(result.ToSentence(SendCommandToInstanceResultExtensions.ToSentence));
|
||||
}
|
||||
|
||||
await commandInputElement.FocusAsync(preventScroll: true);
|
||||
|
@ -63,7 +63,7 @@
|
||||
form.SubmitModel.StopSubmitting();
|
||||
}
|
||||
else {
|
||||
form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence));
|
||||
form.SubmitModel.StopSubmitting(result.ToSentence(StopInstanceResultExtensions.ToSentence));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,94 +0,0 @@
|
||||
using Phantom.Common.Data.Instance;
|
||||
using Phantom.Common.Data.Replies;
|
||||
using Phantom.Common.Data.Web.Minecraft;
|
||||
using Phantom.Common.Data.Web.Users;
|
||||
|
||||
namespace Phantom.Web.Utils;
|
||||
|
||||
static class Messages {
|
||||
public static string ToSentences(this AddUserError error, string delimiter) {
|
||||
return error switch {
|
||||
AddUserError.NameIsInvalid e => e.Violation.ToSentence(),
|
||||
AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
|
||||
AddUserError.NameAlreadyExists => "Username is already occupied.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentences(this SetUserPasswordError error, string delimiter) {
|
||||
return error switch {
|
||||
SetUserPasswordError.UserNotFound => "User not found.",
|
||||
SetUserPasswordError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this UsernameRequirementViolation violation) {
|
||||
return violation switch {
|
||||
UsernameRequirementViolation.IsEmpty => "Username must not be empty.",
|
||||
UsernameRequirementViolation.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this PasswordRequirementViolation violation) {
|
||||
return violation switch {
|
||||
PasswordRequirementViolation.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.",
|
||||
PasswordRequirementViolation.LowercaseLetterRequired => "Password must contain a lowercase letter.",
|
||||
PasswordRequirementViolation.UppercaseLetterRequired => "Password must contain an uppercase letter.",
|
||||
PasswordRequirementViolation.DigitRequired => "Password must contain a digit.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this JvmArgumentsHelper.ValidationError? result) {
|
||||
return result switch {
|
||||
JvmArgumentsHelper.ValidationError.InvalidFormat => "Invalid format.",
|
||||
JvmArgumentsHelper.ValidationError.XmxNotAllowed => "The -Xmx argument must not be specified manually.",
|
||||
JvmArgumentsHelper.ValidationError.XmsNotAllowed => "The -Xms argument must not be specified manually.",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(result), result, null)
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this LaunchInstanceResult reason) {
|
||||
return reason switch {
|
||||
LaunchInstanceResult.LaunchInitiated => "Launch initiated.",
|
||||
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
|
||||
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
|
||||
LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.",
|
||||
LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this InstanceLaunchFailReason reason) {
|
||||
return reason switch {
|
||||
InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.",
|
||||
InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.",
|
||||
InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.",
|
||||
InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.",
|
||||
InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.",
|
||||
InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.",
|
||||
InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.",
|
||||
InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher => "Could not prepare Minecraft server launcher.",
|
||||
InstanceLaunchFailReason.CouldNotStartMinecraftServer => "Could not start Minecraft server.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this SendCommandToInstanceResult reason) {
|
||||
return reason switch {
|
||||
SendCommandToInstanceResult.Success => "Command sent.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToSentence(this StopInstanceResult reason) {
|
||||
return reason switch {
|
||||
StopInstanceResult.StopInitiated => "Stopping initiated.",
|
||||
StopInstanceResult.InstanceAlreadyStopping => "Instance is already stopping.",
|
||||
StopInstanceResult.InstanceAlreadyStopped => "Instance is already stopped.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@
|
||||
@using Phantom.Web.Components.Tables
|
||||
@using Phantom.Web.Identity
|
||||
@using Phantom.Web.Identity.Authorization
|
||||
@using Phantom.Web.Identity.Data
|
||||
@using Phantom.Web.Layout
|
||||
@using Phantom.Web.Shared
|
||||
@using Phantom.Web.Utils
|
||||
|
Loading…
Reference in New Issue
Block a user