using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; using PrivaPub.Models.User; using PrivaPub.Services; using PrivaPub.Services.ClientToServer.Private; using PrivaPub.StaticServices; using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; namespace PrivaPub.Infrastructure.Cli { // `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services. public static class AdminCommands { public const string SmokeLogin = "deploy-smoke"; const string Usage = """ usage: PrivaPub admin promote|demote PrivaPub admin create-root [--admin] the password is read from standard input PrivaPub admin smoke prints " " for the deploy's signed-in check """; public static async Task Run(string[] args, IServiceProvider services, TextReader input = default, TextWriter output = default) { input ??= Console.In; output ??= Console.Out; switch (args) { case [("promote" or "demote") and var verb, var userName]: return await Promote(verb == "promote", userName, output); case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"): return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output); case ["smoke", var persona]: return await Smoke(services, persona, output); default: Console.Error.WriteLine(Usage); return 2; } } static async Task Promote(bool promote, string userName, TextWriter output) { userName = userName.ToLowerInvariant(); var user = await DB.Default.Find().Match(u => u.UserName == userName).ExecuteFirstAsync(); if (user == default) { Console.Error.WriteLine($"no root user '{userName}'"); return 1; } user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator); if (promote) user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator }); if (!user.Policies.Contains(Policies.IsUser)) user.Policies.Add(Policies.IsUser); user.UpdatedAt = DateTime.UtcNow; await DB.Default.SaveAsync(user); output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}"); return 0; } // With sign-up closed this is how the first root is made; group invitations make the rest. static async Task CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output) { var form = new LoginForm { UserName = login, Password = password?.Trim() }; var problems = new List(); if (!Validator.TryValidateObject(form, new ValidationContext(form), problems, validateAllProperties: true)) { Console.Error.WriteLine(string.Join(Environment.NewLine, problems.Select(p => p.ErrorMessage))); return 1; } var created = await services.GetRequiredService().SignUpAsync(form); if (!created.IsValid) { Console.Error.WriteLine(created.ErrorMessage); return 1; } return admin ? await Promote(true, login, output) : await Report(login, output); } static Task Report(string login, TextWriter output) { output.WriteLine($"{login.ToLowerInvariant()}: created"); return Task.FromResult(0); } // The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run // sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person. static async Task Smoke(IServiceProvider services, string personaName, TextWriter output) { personaName = personaName.ToLowerInvariant(); var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24)); var root = await DB.Default.Find().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync(); if (root == default) { var created = await services.GetRequiredService().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password }); if (!created.IsValid) { Console.Error.WriteLine(created.ErrorMessage); return 1; } root = await DB.Default.Find().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync(); } else if (root.DeletedAt.HasValue || root.IsBanned) { Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned"); return 1; } else await DB.Default.Update().MatchID(root.ID) .Modify(u => u.HashedPassword, services.GetRequiredService().Hash(password)) .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); var owned = (await DB.Default.Find().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList(); var persona = await DB.Default.Find().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync(); if (persona == default) { var inserted = await services.GetRequiredService().InsertAvatar(new InsertAvatarForm { RootId = root.ID, UserName = personaName, Name = personaName, Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona." }); if (!inserted.IsValid) { Console.Error.WriteLine(inserted.ErrorMessage); return 1; } persona = await DB.Default.Find().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync(); } await DB.Default.Update().MatchID(persona.ID) .Modify(a => a.Settings.IsDiscoverable, false) .Modify(a => a.Settings.IsIndexable, false) .ExecuteAsync(); output.WriteLine($"{SmokeLogin} {password}"); return 0; } } }