1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-09-16 00:32:12 +02:00

5 Commits

255 changed files with 1797 additions and 1709 deletions

View File

@@ -105,7 +105,7 @@ public abstract class BaseLauncher : IServerLauncher {
private static async Task AcceptEula(InstanceProperties instanceProperties) { private static async Task AcceptEula(InstanceProperties instanceProperties) {
var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt"); var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt");
await File.WriteAllLinesAsync(eulaFilePath, new [] { "# EULA", "eula=true" }, Encoding.UTF8); await File.WriteAllLinesAsync(eulaFilePath, new[] { "# EULA", "eula=true" }, Encoding.UTF8);
} }
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) { private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {

View File

@@ -3,11 +3,12 @@ using System.Buffers.Binary;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
public static class ServerStatusProtocol { public static class ServerStatusProtocol {
public static async Task<int> GetOnlinePlayerCount(ushort serverPort, CancellationToken cancellationToken) { public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient(); using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken); await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream(); var tcpStream = tcpClient.GetStream();
@@ -17,7 +18,7 @@ public static class ServerStatusProtocol {
await tcpStream.FlushAsync(cancellationToken); await tcpStream.FlushAsync(cancellationToken);
short messageLength = await ReadStreamHeader(tcpStream, cancellationToken); short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return await ReadOnlinePlayerCount(tcpStream, messageLength * 2, cancellationToken); return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
} }
private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) { private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
@@ -40,35 +41,53 @@ public static class ServerStatusProtocol {
} }
} }
private static async Task<int> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength); var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try { try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken); await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength));
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
throw new ProtocolException("Could not find message separators in response from server.");
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
throw new ProtocolException("Could not parse online player count in response from server: " + onlinePlayerCountStr);
}
return onlinePlayerCount;
} finally { } finally {
ArrayPool<byte>.Shared.Return(messageBuffer); ArrayPool<byte>.Shared.Return(messageBuffer);
} }
} }
/// <summary>
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
/// </summary>
private static readonly byte[] Separator = { 0x00, 0xA7 };
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
int lastSeparator = messageBuffer.LastIndexOf(Separator);
int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
if (lastSeparator == -1 || middleSeparator == -1) {
throw new ProtocolException("Could not find message separators in response from server.");
}
var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
// Player counts are integers, whose maximum string length is 10 characters.
Span<char> integerStringBuffer = stackalloc char[10];
return new InstancePlayerCounts(
DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
);
}
private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
}
if (!int.TryParse(tempCharBuffer, out int playerCount)) {
throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
}
return playerCount;
}
public sealed class ProtocolException : Exception { public sealed class ProtocolException : Exception {
internal ProtocolException(string message) : base(message) {} internal ProtocolException(string message) : base(message) {}
} }

View File

@@ -5,7 +5,6 @@ using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;

View File

@@ -1,5 +1,8 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using Phantom.Utils.Threading; using Phantom.Utils.Threading;
@@ -7,32 +10,35 @@ using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask { sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly InstanceProcess process; private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly ushort serverPort; private readonly ushort serverPort;
private readonly InstanceProcess process;
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource(); private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new (); private readonly ManualResetEventSlim serverOutputEvent = new ();
private int? onlinePlayerCount; private InstancePlayerCounts? playerCounts;
public int? OnlinePlayerCount { public InstancePlayerCounts? PlayerCounts {
get { get {
lock (this) { lock (this) {
return onlinePlayerCount; return playerCounts;
} }
} }
private set { private set {
EventHandler<int?>? onlinePlayerCountChanged; EventHandler<int?>? onlinePlayerCountChanged;
lock (this) { lock (this) {
if (onlinePlayerCount == value) { if (playerCounts == value) {
return; return;
} }
onlinePlayerCount = value; playerCounts = value;
onlinePlayerCountChanged = OnlinePlayerCountChanged; onlinePlayerCountChanged = OnlinePlayerCountChanged;
} }
onlinePlayerCountChanged?.Invoke(this, value); onlinePlayerCountChanged?.Invoke(this, value?.Online);
controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value));
} }
} }
@@ -41,6 +47,8 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private bool isDisposed = false; private bool isDisposed = false;
public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) { public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) {
this.controllerConnection = context.Services.ControllerConnection;
this.instanceGuid = context.InstanceGuid;
this.process = process; this.process = process;
this.serverPort = serverPort; this.serverPort = serverPort;
Start(); Start();
@@ -56,7 +64,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
while (!CancellationToken.IsCancellationRequested) { while (!CancellationToken.IsCancellationRequested) {
serverOutputEvent.Reset(); serverOutputEvent.Reset();
OnlinePlayerCount = await TryGetOnlinePlayerCount(); PlayerCounts = await TryGetPlayerCounts();
if (!firstDetection.Task.IsCompleted) { if (!firstDetection.Task.IsCompleted) {
firstDetection.SetResult(); firstDetection.SetResult();
@@ -68,11 +76,11 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
} }
} }
private async Task<int?> TryGetOnlinePlayerCount() { private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try { try {
int newOnlinePlayerCount = await ServerStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken); var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} online player(s).", newOnlinePlayerCount); Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum);
return newOnlinePlayerCount; return result;
} catch (ServerStatusProtocol.ProtocolException e) { } catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error(e.Message); Logger.Error(e.Message);
return null; return null;
@@ -88,12 +96,12 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource(); var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) { lock (this) {
if (onlinePlayerCount == null) { if (playerCounts is { Online: > 0 }) {
throw new InvalidOperationException();
}
else if (onlinePlayerCount > 0) {
return; return;
} }
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged; OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
@@ -123,7 +131,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
protected override void Dispose() { protected override void Dispose() {
lock (this) { lock (this) {
isDisposed = true; isDisposed = true;
onlinePlayerCount = null; playerCounts = null;
} }
process.RemoveOutputListener(OnOutput); process.RemoveOutputListener(OnOutput);

View File

@@ -8,9 +8,10 @@ public sealed partial record Instance(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] IInstanceStatus Status, [property: MemoryPackOrder(2)] IInstanceStatus Status,
[property: MemoryPackOrder(3)] bool LaunchAutomatically [property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts,
[property: MemoryPackOrder(4)] bool LaunchAutomatically
) { ) {
public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) { public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically); return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically);
} }
} }

View File

@@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct InstancePlayerCounts(
[property: MemoryPackOrder(0)] int Online,
[property: MemoryPackOrder(1)] int Maximum
);

View File

@@ -31,6 +31,7 @@ public static class AgentMessageRegistries {
ToController.Add<InstanceOutputMessage>(5); ToController.Add<InstanceOutputMessage>(5);
ToController.Add<ReportAgentStatusMessage>(6); ToController.Add<ReportAgentStatusMessage>(6);
ToController.Add<ReportInstanceEventMessage>(7); ToController.Add<ReportInstanceEventMessage>(7);
ToController.Add<ReportInstancePlayerCountsMessage>(8);
ToController.Add<ReplyMessage>(127); ToController.Add<ReplyMessage>(127);
} }

View File

@@ -0,0 +1,10 @@
using MemoryPack;
using Phantom.Common.Data.Instance;
namespace Phantom.Common.Messages.Agent.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record ReportInstancePlayerCountsMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstancePlayerCounts? PlayerCounts
) : IMessageToController;

View File

@@ -96,6 +96,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes); Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance); ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus); Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts);
ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand); ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
@@ -159,7 +160,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() { private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>(); var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) { foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken); var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically)); configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
} }
@@ -187,6 +188,8 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand; public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
public sealed record UpdateInstancePlayerCountsCommand(Guid InstanceGuid, InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>; public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record StopInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>; public sealed record StopInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
@@ -342,6 +345,10 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status)); TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
} }
private void UpdateInstancePlayerCounts(UpdateInstancePlayerCountsCommand command) {
TellInstance(command.InstanceGuid, new InstanceActor.SetPlayerCountsCommand(command.PlayerCounts));
}
private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) { private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid)); return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));
} }

View File

@@ -26,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private InstanceConfiguration configuration; private InstanceConfiguration configuration;
private IInstanceStatus status; private IInstanceStatus status;
private InstancePlayerCounts? playerCounts;
private bool launchAutomatically; private bool launchAutomatically;
private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor; private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
@@ -35,11 +36,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
this.agentConnection = init.AgentConnection; this.agentConnection = init.AgentConnection;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
(this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance; (this.instanceGuid, this.configuration, this.status, this.playerCounts, this.launchAutomatically) = init.Instance;
this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
Receive<SetStatusCommand>(SetStatus); Receive<SetStatusCommand>(SetStatus);
Receive<SetPlayerCountsCommand>(SetPlayerCounts);
ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance); ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
@@ -47,7 +49,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
} }
private void NotifyInstanceUpdated() { private void NotifyInstanceUpdated() {
agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically))); agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, playerCounts, launchAutomatically)));
} }
private void SetLaunchAutomatically(bool newValue) { private void SetLaunchAutomatically(bool newValue) {
@@ -66,6 +68,8 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand; public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>; public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>; public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
@@ -76,6 +80,16 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private void SetStatus(SetStatusCommand command) { private void SetStatus(SetStatusCommand command) {
status = command.Status; status = command.Status;
if (!status.IsRunning() && status != InstanceStatus.Offline /* Guard against temporary disconnects */) {
playerCounts = null;
}
NotifyInstanceUpdated();
}
private void SetPlayerCounts(SetPlayerCountsCommand command) {
playerCounts = command.PlayerCounts;
NotifyInstanceUpdated(); NotifyInstanceUpdated();
} }

View File

@@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes); Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<InstanceOutputMessage>(HandleInstanceOutput);
Receive<ReplyMessage>(HandleReply); Receive<ReplyMessage>(HandleReply);
@@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
} }
private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
}
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid)); message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
} }

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
@@ -56,12 +57,12 @@ sealed class UserManager {
wasCreated = true; wasCreated = true;
} }
else { else {
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.CreationFailed(result.Error); return new CreationFailed(result.Error);
} }
} }
else { else {
if (userRepository.SetUserPassword(user, password).TryGetError(out var error)) { if (userRepository.SetUserPassword(user, password).TryGetError(out var error)) {
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UpdatingFailed(error); return new UpdatingFailed(error);
} }
auditLogWriter.AdministratorUserModified(user); auditLogWriter.AdministratorUserModified(user);
@@ -70,7 +71,7 @@ sealed class UserManager {
var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid); var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid);
if (role == null) { if (role == null) {
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.AddingToRoleFailed(); return new AddingToRoleFailed();
} }
await new UserRoleRepository(db).Add(user, role); await new UserRoleRepository(db).Add(user, role);
@@ -84,10 +85,10 @@ sealed class UserManager {
Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
} }
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.Success(user.ToUserInfo()); return new Success(user.ToUserInfo());
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username); Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username);
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UnknownError(); return new UnknownError();
} }
} }

View File

@@ -1,8 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<LangVersion>11</LangVersion> <LangVersion>13</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -1,7 +1,7 @@
# +---------------+ # +---------------+
# | Prepare build | # | Prepare build |
# +---------------+ # +---------------+
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/nightly/sdk:8.0 AS phantom-builder FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/nightly/sdk:9.0 AS phantom-builder
ARG TARGETARCH ARG TARGETARCH
ADD . /app ADD . /app
@@ -19,7 +19,7 @@ RUN find .artifacts/publish/*/* -maxdepth 0 -execdir mv '{}' 'release' \;
# +---------------------+ # +---------------------+
# | Phantom Agent image | # | Phantom Agent image |
# +---------------------+ # +---------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0 AS phantom-agent FROM mcr.microsoft.com/dotnet/nightly/runtime:9.0 AS phantom-agent
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data
@@ -27,7 +27,7 @@ WORKDIR /data
COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8 COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8
COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16 COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16
COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17 COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17
COPY --from=eclipse-temurin:20-jre /opt/java/openjdk /opt/java/20 COPY --from=eclipse-temurin:21-jre /opt/java/openjdk /opt/java/21
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -46,7 +46,7 @@ ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"]
# +--------------------------+ # +--------------------------+
# | Phantom Controller image | # | Phantom Controller image |
# +--------------------------+ # +--------------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0 AS phantom-controller FROM mcr.microsoft.com/dotnet/nightly/runtime:9.0 AS phantom-controller
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data
@@ -59,7 +59,7 @@ ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"]
# +-------------------+ # +-------------------+
# | Phantom Web image | # | Phantom Web image |
# +-------------------+ # +-------------------+
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0 AS phantom-web FROM mcr.microsoft.com/dotnet/nightly/aspnet:9.0 AS phantom-web
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data

View File

@@ -59,25 +59,25 @@ public sealed class RingBufferTests {
[Test] [Test]
public void AddOneItemAndEnumerateOne() { public void AddOneItemAndEnumerateOne() {
var buffer = PrepareRingBuffer(10, "a"); var buffer = PrepareRingBuffer(10, "a");
Assert.That(buffer.EnumerateLast(1), Is.EquivalentTo(new [] { "a" })); Assert.That(buffer.EnumerateLast(1), Is.EquivalentTo(new[] { "a" }));
} }
[Test] [Test]
public void AddOneItemAndEnumerateMaxValue() { public void AddOneItemAndEnumerateMaxValue() {
var buffer = PrepareRingBuffer(10, "a"); var buffer = PrepareRingBuffer(10, "a");
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a" })); Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new[] { "a" }));
} }
[Test] [Test]
public void AddMultipleItemsWithinCapacityAndEnumerateFewer() { public void AddMultipleItemsWithinCapacityAndEnumerateFewer() {
var buffer = PrepareRingBuffer(10, "a", "b", "c"); var buffer = PrepareRingBuffer(10, "a", "b", "c");
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "b", "c" })); Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new[] { "b", "c" }));
} }
[Test] [Test]
public void AddMultipleItemsWithinCapacityAndEnumerateMaxValue() { public void AddMultipleItemsWithinCapacityAndEnumerateMaxValue() {
var buffer = PrepareRingBuffer(10, "a", "b", "c"); var buffer = PrepareRingBuffer(10, "a", "b", "c");
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a", "b", "c" })); Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new[] { "a", "b", "c" }));
} }
[TestCase(3)] [TestCase(3)]
@@ -85,12 +85,12 @@ public sealed class RingBufferTests {
[TestCase(5)] [TestCase(5)]
public void AddMultipleItemsOverflowingCapacityAndEnumerateFewer(int capacity) { public void AddMultipleItemsOverflowingCapacityAndEnumerateFewer(int capacity) {
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f"); var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "e", "f" })); Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new[] { "e", "f" }));
} }
[TestCase(3, ExpectedResult = new [] { "d", "e", "f" })] [TestCase(3, ExpectedResult = new[] { "d", "e", "f" })]
[TestCase(4, ExpectedResult = new [] { "c", "d", "e", "f" })] [TestCase(4, ExpectedResult = new[] { "c", "d", "e", "f" })]
[TestCase(5, ExpectedResult = new [] { "b", "c", "d", "e", "f" })] [TestCase(5, ExpectedResult = new[] { "b", "c", "d", "e", "f" })]
public string[] AddMultipleItemsOverflowingCapacityAndEnumerateMaxValue(int capacity) { public string[] AddMultipleItemsOverflowingCapacityAndEnumerateMaxValue(int capacity) {
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f"); var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
return buffer.EnumerateLast(uint.MaxValue).ToArray(); return buffer.EnumerateLast(uint.MaxValue).ToArray();

View File

@@ -9,12 +9,12 @@ public abstract class FormCustomValidationAttribute<TModel, TValue> : Validation
protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) { protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) {
if (value is not TValue typedValue) { if (value is not TValue typedValue) {
return new ValidationResult(null, new [] { FieldName }); return new ValidationResult(null, new[] { FieldName });
} }
var model = (TModel) validationContext.ObjectInstance; var model = (TModel) validationContext.ObjectInstance;
var result = Validate(model, typedValue); var result = Validate(model, typedValue);
return result == ValidationResult.Success ? result : new ValidationResult(result?.ErrorMessage, new [] { FieldName }); return result == ValidationResult.Success ? result : new ValidationResult(result?.ErrorMessage, new[] { FieldName });
} }
protected abstract string FieldName { get; } protected abstract string FieldName { get; }

View File

@@ -9,7 +9,7 @@ public abstract class FormValidationAttribute<TModel, TValue> : ValidationAttrib
protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) { protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) {
var model = (TModel) validationContext.ObjectInstance; var model = (TModel) validationContext.ObjectInstance;
return value is TValue typedValue && IsValid(model, typedValue) ? ValidationResult.Success : new ValidationResult(null, new [] { FieldName }); return value is TValue typedValue && IsValid(model, typedValue) ? ValidationResult.Success : new ValidationResult(null, new[] { FieldName });
} }
protected abstract string FieldName { get; } protected abstract string FieldName { get; }

View File

@@ -1,6 +1,6 @@
@using Phantom.Web.Services.Authentication @using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services @using Phantom.Web.Services
@using Phantom.Common.Data.Web.Users @using Phantom.Web.Services.Authentication
@inject ApplicationProperties ApplicationProperties @inject ApplicationProperties ApplicationProperties
<div class="navbar navbar-dark"> <div class="navbar navbar-dark">

View File

@@ -3,8 +3,8 @@
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.AuditLog @using Phantom.Common.Data.Web.AuditLog
@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Users
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@using Phantom.Web.Services.Users
@inherits PhantomComponent @inherits PhantomComponent
@inject AuditLogManager AuditLogManager @inject AuditLogManager AuditLogManager
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager

View File

@@ -6,7 +6,7 @@
@using Phantom.Web.Services.Agents @using Phantom.Web.Services.Agents
@using Phantom.Web.Services.Events @using Phantom.Web.Services.Events
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@inherits Phantom.Web.Components.PhantomComponent @inherits PhantomComponent
@inject AgentManager AgentManager @inject AgentManager AgentManager
@inject EventLogManager EventLogManager @inject EventLogManager EventLogManager
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager

View File

@@ -1,5 +1,5 @@
@page "/" @page "/"
@inherits Phantom.Web.Components.PhantomComponent @inherits PhantomComponent
<h1>Home</h1> <h1>Home</h1>

View File

@@ -21,6 +21,7 @@
<Column Width="40%">Agent</Column> <Column Width="40%">Agent</Column>
<Column Width="40%">Name</Column> <Column Width="40%">Name</Column>
<Column MinWidth="215px">Status</Column> <Column MinWidth="215px">Status</Column>
<Column Class="text-center" MinWidth="120px">Players</Column>
<Column Width="20%">Version</Column> <Column Width="20%">Version</Column>
<Column Class="text-center" MinWidth="110px">Server Port</Column> <Column Class="text-center" MinWidth="110px">Server Port</Column>
<Column Class="text-center" MinWidth="110px">Rcon Port</Column> <Column Class="text-center" MinWidth="110px">Rcon Port</Column>
@@ -40,6 +41,14 @@
<Cell> <Cell>
<InstanceStatusText Status="instance.Status" /> <InstanceStatusText Status="instance.Status" />
</Cell> </Cell>
<Cell class="text-center">
@if (instance.PlayerCounts is var (online, maximum)) {
<p class="font-monospace">@online.ToString() / @maximum.ToString()</p>
}
else {
<p class="font-monospace">-</p>
}
</Cell>
<Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell> <Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell>
<Cell class="text-center"> <Cell class="text-center">
<p class="font-monospace">@configuration.ServerPort.ToString()</p> <p class="font-monospace">@configuration.ServerPort.ToString()</p>

View File

@@ -1,7 +1,7 @@
@page "/login" @page "/login"
@using System.ComponentModel.DataAnnotations
@using Phantom.Web.Services @using Phantom.Web.Services
@using Phantom.Web.Services.Authentication @using Phantom.Web.Services.Authentication
@using System.ComponentModel.DataAnnotations
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@inject Navigation Navigation @inject Navigation Navigation
@inject UserLoginManager LoginManager @inject UserLoginManager LoginManager

View File

@@ -1,4 +1,6 @@
@page "/setup" @page "/setup"
@using System.ComponentModel.DataAnnotations
@using System.Security.Cryptography
@using Phantom.Common.Data @using Phantom.Common.Data
@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Web.Users
@using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults @using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults
@@ -7,8 +9,6 @@
@using Phantom.Web.Services @using Phantom.Web.Services
@using Phantom.Web.Services.Authentication @using Phantom.Web.Services.Authentication
@using Phantom.Web.Services.Rpc @using Phantom.Web.Services.Rpc
@using System.ComponentModel.DataAnnotations
@using System.Security.Cryptography
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@inject ApplicationProperties ApplicationProperties @inject ApplicationProperties ApplicationProperties
@inject UserLoginManager LoginManager @inject UserLoginManager LoginManager
@@ -44,7 +44,7 @@
@code { @code {
private readonly CreateAdministratorAccountFormModel form = new(); private readonly CreateAdministratorAccountFormModel form = new ();
private sealed class CreateAdministratorAccountFormModel : FormModel { private sealed class CreateAdministratorAccountFormModel : FormModel {
[Required] [Required]

View File

@@ -1,11 +1,11 @@
@page "/users" @page "/users"
@attribute [Authorize(Permission.ViewUsersPolicy)] @attribute [Authorize(Permission.ViewUsersPolicy)]
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Authentication @using Phantom.Web.Services.Authentication
@using Phantom.Web.Services.Authorization @using Phantom.Web.Services.Authorization
@using Phantom.Web.Services.Users @using Phantom.Web.Services.Users
@using Phantom.Common.Data.Web.Users @inherits PhantomComponent
@inherits Phantom.Web.Components.PhantomComponent
@inject UserManager UserManager @inject UserManager UserManager
@inject RoleManager RoleManager @inject RoleManager RoleManager
@inject UserRoleManager UserRoleManager @inject UserRoleManager UserRoleManager

View File

@@ -1,6 +1,6 @@
@using Phantom.Common.Data.Instance @using Phantom.Common.Data.Instance
<nobr> <nobr>
@switch (Status) { @switch (Status) {
case InstanceIsOffline: case InstanceIsOffline:
<span class="fw-semibold">Offline</span> <span class="fw-semibold">Offline</span>
break; break;
@@ -50,7 +50,7 @@
default: default:
<span class="fw-semibold">Unknown</span> <span class="fw-semibold">Unknown</span>
break; break;
} }
</nobr> </nobr>
@code { @code {

View File

@@ -27,7 +27,7 @@
@code { @code {
private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty; private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty;
private List<RoleItem> items = new(); private List<RoleItem> items = new ();
protected override async Task BeforeShown(UserInfo user) { protected override async Task BeforeShown(UserInfo user) {
var allRoles = await RoleManager.GetAll(CancellationToken); var allRoles = await RoleManager.GetAll(CancellationToken);

View File

@@ -2,22 +2,27 @@
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Minecraft; using Phantom.Common.Data.Web.Minecraft;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Common.Data.Web.Users.AddUserErrors;
using Phantom.Common.Data.Web.Users.PasswordRequirementViolations;
using Phantom.Common.Data.Web.Users.SetUserPasswordErrors;
using Phantom.Common.Data.Web.Users.UsernameRequirementViolations;
using PasswordIsInvalid = Phantom.Common.Data.Web.Users.AddUserErrors.PasswordIsInvalid;
namespace Phantom.Web.Utils; namespace Phantom.Web.Utils;
static class Messages { static class Messages {
public static string ToSentences(this AddUserError error, string delimiter) { public static string ToSentences(this AddUserError error, string delimiter) {
return error switch { return error switch {
Common.Data.Web.Users.AddUserErrors.NameIsInvalid e => e.Violation.ToSentence(), NameIsInvalid e => e.Violation.ToSentence(),
Common.Data.Web.Users.AddUserErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
Common.Data.Web.Users.AddUserErrors.NameAlreadyExists => "Username is already occupied.", NameAlreadyExists => "Username is already occupied.",
_ => "Unknown error." _ => "Unknown error."
}; };
} }
public static string ToSentences(this SetUserPasswordError error, string delimiter) { public static string ToSentences(this SetUserPasswordError error, string delimiter) {
return error switch { return error switch {
Common.Data.Web.Users.SetUserPasswordErrors.UserNotFound => "User not found.", UserNotFound => "User not found.",
Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
_ => "Unknown error." _ => "Unknown error."
}; };
@@ -25,18 +30,18 @@ static class Messages {
public static string ToSentence(this UsernameRequirementViolation violation) { public static string ToSentence(this UsernameRequirementViolation violation) {
return violation switch { return violation switch {
Common.Data.Web.Users.UsernameRequirementViolations.IsEmpty => "Username must not be empty.", IsEmpty => "Username must not be empty.",
Common.Data.Web.Users.UsernameRequirementViolations.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).", TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).",
_ => "Unknown error." _ => "Unknown error."
}; };
} }
public static string ToSentence(this PasswordRequirementViolation violation) { public static string ToSentence(this PasswordRequirementViolation violation) {
return violation switch { return violation switch {
Common.Data.Web.Users.PasswordRequirementViolations.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.", TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.",
Common.Data.Web.Users.PasswordRequirementViolations.MustContainLowercaseLetter => "Password must contain a lowercase letter.", MustContainLowercaseLetter => "Password must contain a lowercase letter.",
Common.Data.Web.Users.PasswordRequirementViolations.MustContainUppercaseLetter => "Password must contain an uppercase letter.", MustContainUppercaseLetter => "Password must contain an uppercase letter.",
Common.Data.Web.Users.PasswordRequirementViolations.MustContainDigit => "Password must contain a digit.", MustContainDigit => "Password must contain a digit.",
_ => "Unknown error." _ => "Unknown error."
}; };
} }

View File

@@ -1,6 +1,6 @@
{ {
"sdk": { "sdk": {
"version": "8.0.0", "version": "9.0.0",
"rollForward": "latestMinor", "rollForward": "latestMinor",
"allowPrerelease": true "allowPrerelease": true
} }