2026-10-01 10:58:39 +02:00
using MongoDB.Entities ;
using PrivaPub.ClientModels ;
2026-10-04 02:37:38 +02:00
using PrivaPub.ClientModels.User ;
using PrivaPub.ClientModels.User.Avatar ;
2026-10-07 11:55:17 +02:00
using PrivaPub.Infrastructure.Backup ;
2026-10-01 10:58:39 +02:00
using PrivaPub.Models.User ;
2026-10-04 02:37:38 +02:00
using PrivaPub.Services ;
using PrivaPub.Services.ClientToServer.Private ;
using PrivaPub.StaticServices ;
using System.ComponentModel.DataAnnotations ;
using System.Security.Cryptography ;
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
public const string SmokeLogin = "deploy-smoke" ;
const string Usage = """
usage : PrivaPub admin promote | demote < root >
PrivaPub admin create - root < login > [-- admin ] the password is read from standard input
PrivaPub admin smoke < persona > prints "<login> <password>" for the deploy ' s signed - in check
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 );
case [ "smoke" , var persona ]:
return await Smoke ( services , persona , 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 ;
}
user . Policies . RemoveAll ( p => p is Policies . IsAdmin or Policies . IsModerator );
2026-10-04 02:37:38 +02:00
if ( promote )
2026-10-01 10:58:39 +02:00
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 );
2026-10-04 02:37:38 +02:00
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 < 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 );
}
// 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 < int > 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 < RootUser >(). Match ( u => u . UserName == SmokeLogin ). ExecuteFirstAsync ();
if ( root == default )
{
var created = await services . GetRequiredService < IRootUsersService >(). SignUpAsync ( new LoginForm { UserName = SmokeLogin , Password = password });
if (! created . IsValid )
{
Console . Error . WriteLine ( created . ErrorMessage );
return 1 ;
}
root = await DB . Default . Find < RootUser >(). 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 < RootUser >(). MatchID ( root . ID )
. Modify ( u => u . HashedPassword , services . GetRequiredService < IPasswordHasher >(). Hash ( password ))
. Modify ( u => u . UpdatedAt , DateTime . UtcNow )
. ExecuteAsync ();
var owned = ( await DB . Default . Find < RootToAvatar >(). Match ( ra => ra . RootId == root . ID ). ExecuteAsync ()). Select ( ra => ra . AvatarId ). ToList ();
var persona = await DB . Default . Find < Avatar >(). Match ( a => owned . Contains ( a . ID ) && a . UserName == personaName && ! a . DeletionAt . HasValue ). ExecuteFirstAsync ();
if ( persona == default )
{
var inserted = await services . GetRequiredService < IPrivateAvatarUsersService >(). 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 < Avatar >(). MatchID ((( ViewAvatar ) inserted . Data ). Id ). ExecuteFirstAsync ();
}
await DB . Default . Update < Avatar >(). MatchID ( persona . ID )
. Modify ( a => a . Settings . IsDiscoverable , false )
. Modify ( a => a . Settings . IsIndexable , false )
. ExecuteAsync ();
output . WriteLine ( $"{SmokeLogin} {password}" );
2026-10-01 10:58:39 +02:00
return 0 ;
}
}
}