Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf2026-10-29 16:34:29 +0300
committerGitHub2026-10-29 16:34:29 +0300
commit6b4e5e403875d3648be383395f1dad50f0d6503d (patch)
tree673620dce10f2723d8badf68b3b30de19ad8e6e0 /apps/api
parent0c0bbeab246a94035b572ce47d3502de997ac58a (diff)
parent3749e30da561652a226017d9360b84e34a8b74fc (diff)
downloadprogmaweb-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.cs118
-rw-r--r--apps/api/Accounts/AccountNamePolicy.cs27
-rw-r--r--apps/api/Accounts/AccountService.cs49
-rw-r--r--apps/api/Program.cs11
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 &lt;Name&gt;
+/// &lt;email&gt;</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(