diff options
| author | Leitwolf | 2026-10-29 16:34:29 +0300 |
|---|---|---|
| committer | GitHub | 2026-10-29 16:34:29 +0300 |
| commit | 6b4e5e403875d3648be383395f1dad50f0d6503d (patch) | |
| tree | 673620dce10f2723d8badf68b3b30de19ad8e6e0 /apps/api | |
| parent | 0c0bbeab246a94035b572ce47d3502de997ac58a (diff) | |
| parent | 3749e30da561652a226017d9360b84e34a8b74fc (diff) | |
| download | progmaweb-6b4e5e403875d3648be383395f1dad50f0d6503d.tar.gz | |
Merge pull request #50 from Progmasoft/feature/provision-reserved-accounts
Provision the reserved accounts on the server
Diffstat (limited to 'apps/api')
| -rw-r--r-- | apps/api/Accounts/AccountCommand.cs | 118 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountNamePolicy.cs | 27 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountService.cs | 49 | ||||
| -rw-r--r-- | apps/api/Program.cs | 11 |
4 files changed, 204 insertions, 1 deletions
diff --git a/apps/api/Accounts/AccountCommand.cs b/apps/api/Accounts/AccountCommand.cs new file mode 100644 index 0000000..ae75564 --- /dev/null +++ b/apps/api/Accounts/AccountCommand.cs @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using System.Text; + +namespace Progmasoft.Progmaweb.Api.Accounts; + +/// <summary> +/// The command that provisions a reserved account on the server: <c>accounts provision-reserved <Name> +/// <email></c>. Public registration refuses the reserved names (Progmasoft, Leitwolf); this is the trusted +/// operations tooling that creates them. The password is read from the terminal without echo, twice, or from one line +/// of standard input when it is not a terminal; it never appears on a command line. +/// </summary> +internal static class AccountCommand +{ + /// <summary>Runs an account command.</summary> + /// <param name="services">The services of the API, not started.</param> + /// <param name="args">The arguments after <c>accounts</c>.</param> + /// <param name="input">Where the password comes from.</param> + /// <param name="output">Where results go.</param> + /// <param name="errors">Where errors go.</param> + /// <param name="cancellationToken">Cancels the command.</param> + /// <returns>The exit code: 0 on success, 1 when refused, 2 for a wrong command line.</returns> + public static async Task<int> RunAsync( + IServiceProvider services, + string[] args, + IPasswordInput input, + TextWriter output, + TextWriter errors, + CancellationToken cancellationToken) + { + if (args is not ["provision-reserved", string name, string email]) + { + await errors.WriteLineAsync("usage: accounts provision-reserved <Name> <email>"); + return 2; + } + + // The service brings the account tables to the current schema when it starts; a command may run before. + if (services.GetService<Npgsql.NpgsqlDataSource>() is { } dataSource) + { + await AccountSchema.ApplyAsync(dataSource, cancellationToken); + } + + string? password = input.Read("Password: "); + if (input.IsTerminal && password != input.Read("Password again: ")) + { + await errors.WriteLineAsync("The two passwords differ; nothing was created."); + return 1; + } + + AccountService accounts = services.GetRequiredService<AccountService>(); + (AccountRecord? account, string? error) = + await accounts.ProvisionReservedAsync(name, email, password, cancellationToken); + if (account is null) + { + await errors.WriteLineAsync(error); + return 1; + } + + await output.WriteLineAsync($"Created the reserved account {account.AccountName} ({account.Email})."); + return 0; + } +} + +/// <summary>Where <see cref="AccountCommand"/> reads a password from.</summary> +internal interface IPasswordInput +{ + /// <summary>Whether the input is a terminal, which asks twice and does not echo.</summary> + bool IsTerminal { get; } + + /// <summary>Reads one password.</summary> + /// <param name="prompt">What to show before it, on a terminal.</param> + /// <returns>The password, or <see langword="null"/> at the end of the input.</returns> + string? Read(string prompt); +} + +/// <summary>The console: a terminal without echo, or one line of redirected standard input.</summary> +internal sealed class ConsolePasswordInput : IPasswordInput +{ + /// <inheritdoc/> + public bool IsTerminal => !Console.IsInputRedirected; + + /// <inheritdoc/> + public string? Read(string prompt) + { + if (!IsTerminal) + { + return Console.In.ReadLine(); + } + + Console.Error.Write(prompt); + StringBuilder password = new(); + while (true) + { + ConsoleKeyInfo key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.Error.WriteLine(); + return password.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (password.Length > 0) + { + password.Length--; + } + + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + } + } + } +} diff --git a/apps/api/Accounts/AccountNamePolicy.cs b/apps/api/Accounts/AccountNamePolicy.cs index 3b2328d..7dce6c5 100644 --- a/apps/api/Accounts/AccountNamePolicy.cs +++ b/apps/api/Accounts/AccountNamePolicy.cs @@ -61,6 +61,33 @@ internal static partial class AccountNamePolicy return true; } + /// <summary> + /// Validates a reserved name for the operations command that provisions it: the same rules as + /// <see cref="TryNormalize"/>, except that the name must be one of the reserved names. + /// </summary> + /// <param name="value">The name as entered; surrounding white space is ignored.</param> + /// <param name="canonical">The trimmed name. It is meaningful only when the method returns <see langword="true"/>.</param> + /// <param name="error">Message for the operator when the name is refused; empty otherwise.</param> + /// <returns><see langword="true"/> when the name is a well-formed reserved name.</returns> + public static bool TryNormalizeReserved(string? value, out string canonical, out string error) + { + canonical = value?.Trim() ?? string.Empty; + if (!CanonicalPattern().IsMatch(canonical)) + { + error = "Account names must begin with an uppercase ASCII letter and contain only ASCII letters or digits."; + return false; + } + + if (!ReservedNames.Contains(NormalizeForLookup(canonical))) + { + error = $"'{canonical}' is not a reserved name; it is registered through the account site."; + return false; + } + + error = string.Empty; + return true; + } + /// <summary>Returns the key under which a name is compared for uniqueness.</summary> /// <param name="canonical">A name that <see cref="TryNormalize"/> accepted.</param> /// <returns>The name in upper case.</returns> diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index 0deadb5..40e971d 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -224,6 +224,55 @@ internal sealed class AccountService( return (await sessions.CreateAsync(result.Account ?? account, cancellationToken), GoogleAccountFailure.None); } + /// <summary> + /// Creates the account of a reserved name, which public registration refuses. Only trusted operations tooling + /// calls this; see <see cref="AccountCommand"/>. + /// </summary> + /// <param name="accountName">The reserved name, as it is to be spelled.</param> + /// <param name="email">The email address of the account.</param> + /// <param name="password">The password, which the operator typed.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The created account, or the message that says why it was refused.</returns> + public async ValueTask<(AccountRecord? Account, string? Error)> ProvisionReservedAsync( + string? accountName, + string? email, + string? password, + CancellationToken cancellationToken) + { + if (!AccountNamePolicy.TryNormalizeReserved(accountName, out string name, out string nameError)) + { + return (null, nameError); + } + + if (!EmailAddressPolicy.TryNormalize(email, out string address, out string normalizedEmail, out string emailError)) + { + return (null, emailError); + } + + if (!PasswordPolicy.TryValidate(password, out string passwordError)) + { + return (null, passwordError); + } + + AccountRecord provisional = new( + Guid.NewGuid(), + name, + AccountNamePolicy.NormalizeForLookup(name), + address, + normalizedEmail, + null, + string.Empty, + timeProvider.GetUtcNow()); + AccountRecord account = provisional with { PasswordHash = passwordHasher.HashPassword(provisional, password!) }; + CreateAccountResult result = await store.CreateAsync(account, cancellationToken); + return result.Status switch + { + CreateAccountStatus.AccountNameUnavailable => (null, $"The account '{name}' exists already."), + CreateAccountStatus.EmailUnavailable => (null, "That email address is already registered."), + _ => (result.Account ?? account, null) + }; + } + /// <summary>Validates every field of a registration and collects the messages of the refused ones.</summary> /// <param name="request">The registration body.</param> /// <param name="accountName">The canonical account name.</param> diff --git a/apps/api/Program.cs b/apps/api/Program.cs index 873c120..6208b30 100644 --- a/apps/api/Program.cs +++ b/apps/api/Program.cs @@ -13,8 +13,10 @@ using Progmasoft.Progmaweb.Api.ViGet; // `viget ...` runs a command of ViGet with the configuration of the service instead of the service itself; its // arguments are not configuration. +// `accounts ...` provisions a reserved account the same way. bool vigetCommand = ViGetCommand.IsCommand(args); -WebApplicationBuilder builder = WebApplication.CreateBuilder(vigetCommand ? [] : args); +bool accountCommand = args is ["accounts", ..]; +WebApplicationBuilder builder = WebApplication.CreateBuilder(vigetCommand || accountCommand ? [] : args); builder.Services.Configure<AccountSessionOptions>(builder.Configuration.GetSection(AccountSessionOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); @@ -109,6 +111,13 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = WebApplication app = builder.Build(); +if (accountCommand) +{ + Environment.ExitCode = await AccountCommand.RunAsync( + app.Services, args[1..], new ConsolePasswordInput(), Console.Out, Console.Error, CancellationToken.None); + return; +} + if (vigetCommand) { Environment.ExitCode = await ViGetCommand.RunAsync( |