2026-10-01 10:58:39 +02:00
using MongoDB.Entities ;
2026-10-17 00:12:56 +02:00
using OpenIddict.Abstractions ;
2026-10-01 10:58:39 +02:00
using PrivaPub.ClientModels ;
2026-10-04 02:37:38 +02:00
using PrivaPub.ClientModels.User ;
2026-10-07 11:55:17 +02:00
using PrivaPub.Infrastructure.Backup ;
2026-10-16 23:21:41 +02:00
using PrivaPub.Models ;
2026-10-01 10:58:39 +02:00
using PrivaPub.Models.User ;
2026-10-04 02:37:38 +02:00
using PrivaPub.Services ;
using System.ComponentModel.DataAnnotations ;
2026-10-01 10:58:39 +02:00
namespace PrivaPub.Infrastructure.Cli
{
2026-10-04 02:37:38 +02:00
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
2026-10-01 10:58:39 +02:00
public static class AdminCommands
{
2026-10-04 02:37:38 +02:00
const string Usage = """
usage : PrivaPub admin promote | demote < root >
PrivaPub admin create - root < login > [-- admin ] the password is read from standard input
2026-10-17 00:12:56 +02:00
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
2026-10-17 03:18:19 +02:00
PrivaPub admin reset - totp < root > turns a login ' s two - factor sign - in off
2026-10-07 10:31:14 +02:00
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
2026-10-07 11:40:06 +02:00
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
2026-10-07 11:55:17 +02:00
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
2026-10-04 02:37:38 +02:00
""";
2026-10-01 10:58:39 +02:00
2026-10-07 11:40:06 +02:00
/// <summary>Whether a command runs before migrations: backups are of the database as it was.</summary>
2026-10-07 11:55:17 +02:00
public static bool BeforeMigrations ( string [] args ) => args is [ "backup" , ..] or [ "backups" ] or [ "restore" , ..];
2026-10-07 11:40:06 +02:00
2026-10-04 02:37:38 +02:00
public static async Task < int > Run ( string [] args , IServiceProvider services , TextReader input = default , TextWriter output = default )
2026-10-01 10:58:39 +02:00
{
2026-10-04 02:37:38 +02:00
input ??= Console . In ;
output ??= Console . Out ;
switch ( args )
2026-10-01 10:58:39 +02:00
{
2026-10-04 02:37:38 +02:00
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 );
2026-10-17 00:12:56 +02:00
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 );
2026-10-17 03:18:19 +02:00
case [ "reset-totp" , var root ]:
return await ResetTotp ( root , output );
2026-10-07 10:31:14 +02:00
case [ "media" , "audit" , .. var flags ] when flags . All ( f => f == "--fix" ):
return await AuditMedia ( services , flags . Contains ( "--fix" ), output );
2026-10-07 11:40:06 +02:00
case [ "backup" , "verify" , var id ]:
return await VerifyBackup ( services , id , output );
case [ "backup" , .. var flags ] when BackupFlags ( flags , out var kind , out var dbOnly ):
2026-10-07 11:55:17 +02:00
return await BackUp ( services , kind , dbOnly , output );
2026-10-07 11:40:06 +02:00
case [ "backups" ]:
return ListBackups ( services , output );
2026-10-07 11:55:17 +02:00
case [ "restore" , "--status" ]:
return await RestoreStatus ( services , output );
case [ "restore" , var id ] when ! id . StartsWith ( '-' ):
return await Restore ( services , id , output );
2026-10-04 02:37:38 +02:00
default :
Console . Error . WriteLine ( Usage );
return 2 ;
2026-10-01 10:58:39 +02:00
}
2026-10-04 02:37:38 +02:00
}
2026-10-01 10:58:39 +02:00
2026-10-07 11:40:06 +02:00
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 ;
}
2026-10-07 11:55:17 +02:00
static async Task < int > BackUp ( IServiceProvider services , string kind , bool dbOnly , TextWriter output )
2026-10-07 11:40:06 +02:00
{
2026-10-07 11:55:17 +02:00
var ( made , error ) = await services . GetRequiredService < Backups >(). Create ( kind , dbOnly , CancellationToken . None );
2026-10-07 11:40:06 +02:00
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 )
{
2026-10-07 11:55:17 +02:00
foreach ( var backup in services . GetRequiredService < Backups >(). List ())
2026-10-07 11:40:06 +02:00
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 )
{
2026-10-07 11:55:17 +02:00
var problems = await services . GetRequiredService < Backups >(). Verify ( id , CancellationToken . None );
2026-10-07 11:40:06 +02:00
foreach ( var problem in problems )
output . WriteLine ( problem );
if ( problems . Count > 0 )
return 1 ;
output . WriteLine ( $"{id}: whole" );
return 0 ;
}
2026-10-07 11:55:17 +02:00
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 ;
}
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 );
2026-10-07 10:31:14 +02:00
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 ;
}
2026-10-04 02:37:38 +02:00
static async Task < int > Promote ( bool promote , string userName , TextWriter output )
{
2026-10-01 10:58:39 +02:00
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 ;
}
2026-10-16 23:21:41 +02:00
user . SetAdmin ( promote );
2026-10-01 10:58:39 +02:00
await DB . Default . SaveAsync ( user );
2026-10-16 23:21:41 +02:00
if ( promote )
await ServerSetup . Seal ( user . ID , default ); //an administrator exists: the client's setup is closed
2026-10-01 10:58:39 +02:00
2026-10-04 02:37:38 +02:00
output . WriteLine ( $"{userName}: {string.Join(" , ", user.Policies)}" );
return 0 ;
}
2026-10-16 23:21:41 +02:00
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
// make the rest.
2026-10-04 02:37:38 +02:00
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 ;
}
return admin ? await Promote ( true , login , output ) : await Report ( login , output );
}
static Task < int > Report ( string login , TextWriter output )
{
output . WriteLine ( $"{login.ToLowerInvariant()}: created" );
return Task . FromResult ( 0 );
}
2026-10-17 00:12:56 +02:00
// 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 )
2026-10-04 02:37:38 +02:00
{
2026-10-17 00:12:56 +02:00
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 )
2026-10-04 02:37:38 +02:00
{
2026-10-17 00:12:56 +02:00
Console . Error . WriteLine ( $"no persona '@{personaName}'" );
return 1 ;
2026-10-04 02:37:38 +02:00
}
2026-10-17 00:12:56 +02:00
var root = await DB . Default . Find < RootUser >(). Match ( u => u . UserName == login ). ExecuteFirstAsync ();
if ( root == default || root . DeletedAt . HasValue || root . IsBanned )
2026-10-04 02:37:38 +02:00
{
2026-10-17 00:12:56 +02:00
Console . Error . WriteLine ( $"no root user '{login}' that can take it" );
2026-10-04 02:37:38 +02:00
return 1 ;
}
2026-10-17 00:12:56 +02:00
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 ;
}
2026-10-04 02:37:38 +02:00
2026-10-17 00:12:56 +02:00
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 );
output . WriteLine ( $"@{personaName}: moved to {login}" );
return 0 ;
}
2026-10-17 03:18:19 +02:00
// 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 ( string login , TextWriter output )
{
login = login . ToLowerInvariant ();
var updated = await DB . Default . Update < RootUser >(). Match ( u => u . UserName == login && u . DeletedAt == null )
. Modify ( u => u . Totp , null ). ExecuteAsync ();
if ( updated . MatchedCount == 0 )
{
Console . Error . WriteLine ( $"no root user '{login}'" );
return 1 ;
}
output . WriteLine ( $"{login}: two-factor sign-in is off" );
return 0 ;
}
2026-10-17 00:12:56 +02:00
// 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 ;
}
2026-10-04 02:37:38 +02:00
var owned = ( await DB . Default . Find < RootToAvatar >(). Match ( ra => ra . RootId == root . ID ). ExecuteAsync ()). Select ( ra => ra . AvatarId ). ToList ();
2026-10-17 00:12:56 +02:00
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 ))
2026-10-04 02:37:38 +02:00
{
2026-10-17 00:12:56 +02:00
Console . Error . WriteLine ( $"'{login}' could not be deleted" );
return 1 ;
2026-10-04 02:37:38 +02:00
}
2026-10-17 00:12:56 +02:00
output . WriteLine ( $"{login}: deleted{(personas.Count > 0 ? " , with " + string.Join(" , ", personas) : string.Empty)}" );
2026-10-01 10:58:39 +02:00
return 0 ;
}
}
}