Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
327 lines
16 KiB
C#
327 lines
16 KiB
C#
using MongoDB.Entities;
|
|
|
|
using OpenIddict.Abstractions;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.User;
|
|
using PrivaPub.Domain.Moderation;
|
|
using PrivaPub.Infrastructure.Backup;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Services;
|
|
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace PrivaPub.Infrastructure.Cli
|
|
{
|
|
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
|
|
public static class AdminCommands
|
|
{
|
|
const string Usage = """
|
|
usage: PrivaPub admin promote|demote <root>
|
|
PrivaPub admin create-root <login> [--admin] the password is read from standard input
|
|
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
|
|
PrivaPub admin delete-root <root> deletes a login and every persona it still has
|
|
PrivaPub admin reset-totp <root> turns a login's two-factor sign-in off
|
|
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
|
|
what nothing holds and gives today's pictures their rows
|
|
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
|
|
backs the server up; --db-only lists the media without
|
|
linking them (the deploy's, which can't link www-data's files)
|
|
PrivaPub admin backups the backups kept, newest first
|
|
PrivaPub admin backup verify <id> whether a backup's files are what its manifest says
|
|
PrivaPub admin restore <id> restores a backup: the service stops within seconds and
|
|
restores it when systemd starts it again; everyone signs
|
|
in again, and what was made since is lost, except what
|
|
protects (blocks, deletions, ...)
|
|
PrivaPub admin restore --status the restore waiting, and the last one done
|
|
""";
|
|
|
|
/// <summary>Whether a command runs before migrations: backups are of the database as it was.</summary>
|
|
public static bool BeforeMigrations(string[] args) => args is ["backup", ..] or ["backups"] or ["restore", ..];
|
|
|
|
public static async Task<int> 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(services, 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 ["move-persona", var persona, var root]:
|
|
return await MovePersona(services, persona, root, output);
|
|
case ["delete-root", var root]:
|
|
return await DeleteRoot(services, root, output);
|
|
case ["reset-totp", var root]:
|
|
return await ResetTotp(services, root, output);
|
|
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
|
return await AuditMedia(services, flags.Contains("--fix"), output);
|
|
case ["backup", "verify", var id]:
|
|
return await VerifyBackup(services, id, output);
|
|
case ["backup", .. var flags] when BackupFlags(flags, out var kind, out var dbOnly):
|
|
return await BackUp(services, kind, dbOnly, output);
|
|
case ["backups"]:
|
|
return ListBackups(services, output);
|
|
case ["restore", "--status"]:
|
|
return await RestoreStatus(services, output);
|
|
case ["restore", var id] when !id.StartsWith('-'):
|
|
return await Restore(services, id, output);
|
|
default:
|
|
Console.Error.WriteLine(Usage);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
static readonly string[] CommandKinds = ["manual", "pre-deploy"];
|
|
|
|
static bool BackupFlags(string[] flags, out string kind, out bool dbOnly)
|
|
{
|
|
kind = "manual";
|
|
dbOnly = false;
|
|
for (var i = 0; i < flags.Length; i++)
|
|
switch (flags[i])
|
|
{
|
|
case "--db-only":
|
|
dbOnly = true;
|
|
break;
|
|
case "--kind" when i + 1 < flags.Length && CommandKinds.Contains(flags[i + 1]):
|
|
kind = flags[++i];
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static async Task<int> BackUp(IServiceProvider services, string kind, bool dbOnly, TextWriter output)
|
|
{
|
|
var (made, error) = await services.GetRequiredService<Backups>().Create(kind, dbOnly, CancellationToken.None);
|
|
if (made == default)
|
|
{
|
|
Console.Error.WriteLine(error);
|
|
return 1;
|
|
}
|
|
var manifest = made.Manifest;
|
|
output.WriteLine($"{made.Id}: {manifest.Collections.Count} collections, {manifest.Collections.Sum(c => c.Count)} documents, {made.Bytes / 1024} KiB");
|
|
output.WriteLine(dbOnly
|
|
? $"{manifest.Media.List.Count} media files listed, not linked"
|
|
: $"{manifest.Media.Files} media files, {manifest.Media.Bytes / 1024 / 1024} MiB, {manifest.Media.Missing} missing");
|
|
if (!manifest.Consistent)
|
|
output.WriteLine("not read at one instant: Mongo is not a replica set");
|
|
return 0;
|
|
}
|
|
|
|
static int ListBackups(IServiceProvider services, TextWriter output)
|
|
{
|
|
foreach (var backup in services.GetRequiredService<Backups>().List())
|
|
output.WriteLine($"{backup.Id}\t{backup.Kind}\t{backup.CreatedAt:u}\t{backup.Bytes / 1024} KiB\t{backup.Manifest?.Media.Files ?? 0} media{(backup.Manifest?.DbOnly == true ? " (listed)" : string.Empty)}");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> VerifyBackup(IServiceProvider services, string id, TextWriter output)
|
|
{
|
|
var problems = await services.GetRequiredService<Backups>().Verify(id, CancellationToken.None);
|
|
foreach (var problem in problems)
|
|
output.WriteLine(problem);
|
|
if (problems.Count > 0)
|
|
return 1;
|
|
output.WriteLine($"{id}: whole");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> Restore(IServiceProvider services, string id, TextWriter output)
|
|
{
|
|
var backups = services.GetRequiredService<Backups>();
|
|
var refused = await ServerRestore.Request(backups.Context(), id, "cli", CancellationToken.None);
|
|
if (refused != default)
|
|
{
|
|
Console.Error.WriteLine(refused);
|
|
return 1;
|
|
}
|
|
await Log(services, AdminActions.BackupRestore, AdminTargets.Backup, id, id);
|
|
output.WriteLine($"{id} will be restored: the running service stops within seconds and restores it when it starts again");
|
|
output.WriteLine("(systemd starts it again; follow it with journalctl -u privapub, then PrivaPub admin restore --status)");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> RestoreStatus(IServiceProvider services, TextWriter output)
|
|
{
|
|
var waiting = RestoreMarker.Read(services.GetRequiredService<Backups>().Root);
|
|
if (waiting != default)
|
|
output.WriteLine($"waiting: {waiting.Backup}, {waiting.State}, attempt {waiting.Attempts} of {RestoreMarker.MaxAttempts}, asked by {waiting.RequestedBy} at {waiting.RequestedAt:u}{(waiting.Error == default ? string.Empty : $": {waiting.Error}")}");
|
|
var last = await DB.Default.Find<RestoreRecord>().Sort(r => r.RestoredAt, Order.Descending).ExecuteFirstAsync();
|
|
if (last == default)
|
|
{
|
|
if (waiting == default)
|
|
output.WriteLine("no restore asked for, none done");
|
|
return 0;
|
|
}
|
|
if (last.Abandoned)
|
|
{
|
|
output.WriteLine($"last: {last.Backup} abandoned at {last.RestoredAt:u}, nothing changed: {last.Error}");
|
|
return 0;
|
|
}
|
|
var report = last.Report;
|
|
output.WriteLine($"last: {last.Backup} (made {last.BackupCreatedAt:u}) restored at {last.RestoredAt:u}, asked by {last.RequestedBy}; before it: {last.PreRestore}");
|
|
output.WriteLine($" {report.Documents} documents in {report.Collections} collections, {report.MediaRestored} media files brought back, {report.MediaMissing} missing");
|
|
output.WriteLine($" deleted again: {report.RootsDeleted} roots, {report.PersonasDeleted} personas, {report.GroupsDeleted} groups, {report.PostsDeleted} posts");
|
|
output.WriteLine($" made since, now deleted: roots {Names(report.RootsTombstoned)}, personas {Names(report.PersonasTombstoned)}, groups {Names(report.GroupsTombstoned)}; {report.PostsGone} posts gone");
|
|
output.WriteLine($" {report.ProtectiveKept} protective rows kept, {report.MediaTrashed} media trashed, {report.SessionsEnded} sessions ended");
|
|
return 0;
|
|
}
|
|
|
|
static string Names(List<string> names) => names.Count == 0 ? "none" : string.Join(", ", names);
|
|
|
|
static async Task<int> AuditMedia(IServiceProvider services, bool fix, TextWriter output)
|
|
{
|
|
var report = await Domain.Media.MediaAudit.Run(services.GetRequiredService<Domain.Media.IMediaService>(), fix, CancellationToken.None);
|
|
output.WriteLine($"{report.Files} files served, {report.Held} rows holding media");
|
|
output.WriteLine($"{report.Adopted} pictures personas show without a row{(fix ? ": given one" : string.Empty)}");
|
|
output.WriteLine($"{report.OfDeleted} media of deleted posts or personas{(fix ? ": trashed" : string.Empty)}");
|
|
output.WriteLine($"{report.MissingFiles} rows whose files are missing{(fix ? ": trashed" : string.Empty)}");
|
|
output.WriteLine($"{report.Unheld} files nothing holds{(fix ? ": trashed" : string.Empty)}");
|
|
foreach (var example in report.Examples)
|
|
output.WriteLine($" {example}");
|
|
if (!fix && report.Adopted + report.OfDeleted + report.MissingFiles + report.Unheld > 0)
|
|
output.WriteLine("run again with --fix, as www-data, to apply this; trashed files are deleted after a day");
|
|
return 0;
|
|
}
|
|
|
|
// every act below is kept in the staff's log as the command line's ("cli"): a login's by its name alone, a persona's
|
|
// without the login it went to
|
|
static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) =>
|
|
services.GetRequiredService<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note);
|
|
|
|
static async Task<int> Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false)
|
|
{
|
|
userName = userName.ToLowerInvariant();
|
|
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
|
|
if (user == default)
|
|
{
|
|
Console.Error.WriteLine($"no root user '{userName}'");
|
|
return 1;
|
|
}
|
|
|
|
user.SetAdmin(promote);
|
|
await DB.Default.SaveAsync(user);
|
|
if (promote)
|
|
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
|
|
if (!created)
|
|
await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName);
|
|
|
|
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
|
return 0;
|
|
}
|
|
|
|
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
|
|
// make the rest.
|
|
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
|
|
{
|
|
var form = new LoginForm { UserName = login, Password = password?.Trim() };
|
|
var problems = new List<ValidationResult>();
|
|
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<IRootUsersService>().SignUpAsync(form);
|
|
if (!created.IsValid)
|
|
{
|
|
Console.Error.WriteLine(created.ErrorMessage);
|
|
return 1;
|
|
}
|
|
var made = await DB.Default.Find<RootUser>().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync();
|
|
await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default);
|
|
return admin ? await Promote(services, true, login, output, created: true) : await Report(login, output);
|
|
}
|
|
|
|
static Task<int> Report(string login, TextWriter output)
|
|
{
|
|
output.WriteLine($"{login.ToLowerInvariant()}: created");
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
// A persona given to another login (owner decision 2026-10-17, made for @thepra): its link moves first, so the old
|
|
// login can no longer exchange a token for it, then its Mastodon tokens and authorizations end. Its actor, posts and
|
|
// followers stay as they are, and nobody outside is told: nothing they hold changes.
|
|
static async Task<int> MovePersona(IServiceProvider services, string personaName, string login, TextWriter output)
|
|
{
|
|
personaName = personaName.TrimStart('@').ToLowerInvariant();
|
|
login = login.ToLowerInvariant();
|
|
var persona = await DB.Default.Find<Avatar>().Match(a => a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
|
if (persona == default)
|
|
{
|
|
Console.Error.WriteLine($"no persona '@{personaName}'");
|
|
return 1;
|
|
}
|
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login).ExecuteFirstAsync();
|
|
if (root == default || root.DeletedAt.HasValue || root.IsBanned)
|
|
{
|
|
Console.Error.WriteLine($"no root user '{login}' that can take it");
|
|
return 1;
|
|
}
|
|
var link = await DB.Default.Find<RootToAvatar>().Match(ra => ra.AvatarId == persona.ID).ExecuteFirstAsync();
|
|
if (link == default)
|
|
{
|
|
Console.Error.WriteLine($"'@{personaName}' belongs to no login");
|
|
return 1;
|
|
}
|
|
if (link.RootId == root.ID)
|
|
{
|
|
output.WriteLine($"@{personaName}: already {login}'s");
|
|
return 0;
|
|
}
|
|
|
|
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
|
|
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
|
|
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
|
|
await Log(services, AdminActions.PersonaMove, AdminTargets.Persona, persona.ID, "@" + personaName, "moved to another login");
|
|
output.WriteLine($"@{personaName}: moved to {login}");
|
|
return 0;
|
|
}
|
|
|
|
// The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its
|
|
// password stays.
|
|
static async Task<int> ResetTotp(IServiceProvider services, string login, TextWriter output)
|
|
{
|
|
login = login.ToLowerInvariant();
|
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
|
|
var updated = root == default ? default : await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync();
|
|
if (updated == default || updated.MatchedCount == 0)
|
|
{
|
|
Console.Error.WriteLine($"no root user '{login}'");
|
|
return 1;
|
|
}
|
|
await Log(services, AdminActions.RootResetTotp, AdminTargets.Root, root.ID, login);
|
|
output.WriteLine($"{login}: two-factor sign-in is off");
|
|
return 0;
|
|
}
|
|
|
|
// A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it,
|
|
// announced deleted, and its sessions end.
|
|
static async Task<int> DeleteRoot(IServiceProvider services, string login, TextWriter output)
|
|
{
|
|
login = login.ToLowerInvariant();
|
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
|
|
if (root == default)
|
|
{
|
|
Console.Error.WriteLine($"no root user '{login}'");
|
|
return 1;
|
|
}
|
|
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
|
var personas = (await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync())
|
|
.Select(a => "@" + a.UserName).ToList();
|
|
if (!await services.GetRequiredService<IRootRemoval>().Remove(root.ID, default))
|
|
{
|
|
Console.Error.WriteLine($"'{login}' could not be deleted");
|
|
return 1;
|
|
}
|
|
await Log(services, AdminActions.RootDelete, AdminTargets.Root, root.ID, login);
|
|
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
|
|
return 0;
|
|
}
|
|
}
|
|
}
|