@inherits LocalizableComponentBase @implements IAsyncDisposable @* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here, downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser (js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again, and everyone signs in again. A restore never undoes what protects. *@
@if (IsLoading) {

@Localizer["loading"]

} else if (restarting is not null) { @Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting] } else {
@Localizer["Back up now"] @if (backups.Running is { } running) { @(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."]) } @Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]
@if (backups.Waiting is { } waiting) { @Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]] @if (waiting.Error is { Length: > 0 } error) {
@error }
} @if (backups.LastRestore is { } last) {
@if (last.Abandoned) {

@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]

@last.Error

} else {

@Localizer["Restored {0}", last.Backup] @last.RestoredAt.GetPassedTime(Localizer) @Localizer["asked for by {0}", last.RequestedBy]

@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]

@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]

@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0) {

@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]

}

@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]

}
}

@Localizer["Upload a backup downloaded from here (a .tar file):"]

@Localizer["Upload"]
@if (uploading) {

@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]

}
@if (backups.Backups.Count == 0) {

@Localizer["No backup yet."]

} @foreach (var backup in backups.Backups) {
@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) @KindName(backup.Kind) @backup.CreatedAt.GetPassedTime(Localizer) @((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))

@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections]; @(backup.DbOnly ? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles] : Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)]) @if (backup.MediaMissing > 0) { ; @Localizer["{0} missing", backup.MediaMissing] } @if (!backup.Consistent) { ; @Localizer["not read at one instant"] }

@if (backup.RootsDeletedSince > 0) {

@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]

}
@Localizer["Download"] @Localizer["Restore"] @Localizer["Delete"]
} }
@Localizer["Download a backup"] @if (ticket is null) {

@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]

} else {

@Localizer["The link works for ten minutes, for this backup only."]

@Localizer["Download {0}", ticketSize.GetFileSize(Localizer)] }
@Localizer["Close"] @if (ticket is null) { @Localizer["Get the link"] }
@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]

@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]

@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]

@if (restoring?.RootsDeletedSince > 0) {

@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]

}
@Localizer["Cancel"] @Localizer["Restore"]
@code { [Inject] AdminService AdminService { get; set; } [Inject] ConfirmService Confirm { get; set; } [Inject] TokenAuthStateProvider AuthStateProvider { get; set; } [Inject] AppConfiguration Configuration { get; set; } [Inject] IJSRuntime JS { get; set; } const string UploadInput = "input[data-backup-upload]"; static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3); ViewBackups backups = new(); bool busy; string restarting; ViewBackup downloading; ViewBackup restoring; string password; string typedHost; string ticket; long ticketSize; string dialogError; string pickedName; bool uploading; long uploaded; long uploadSize; double uploadedPercent; IJSObjectReference transfer; DotNetObjectReference self; CancellationTokenSource polling; protected override async Task OnInitializedAsync() { if (CascadingState.IsOffline) { WhenOnline(LoadData); StartOnlinePolling(); IsLoading = false; return; } await LoadData(); IsLoading = false; } async Task LoadData() { backups = await AdminService.Backups() ?? backups; if (backups.Running is not null || backups.Waiting is not null) Poll(); } // while something runs, the list is read again every few seconds; a restore ends the session, and with it this page void Poll() { if (polling is not null) return; polling = new(); var token = polling.Token; _ = Task.Run(async () => { using var timer = new PeriodicTimer(PollEvery); while (await timer.WaitForNextTickAsync(token)) { var read = await AdminService.Backups(token); if (read is not null) backups = read; await InvokeAsync(StateHasChanged); if (restarting is null && read is not null && read.Running is null && read.Waiting is null) break; } polling = null; }, token); } static DColor KindColor(string kind) => kind switch { "nightly" => DColor.Neutral, "manual" => DColor.Primary, "pre-deploy" => DColor.Info, "pre-restore" => DColor.Warning, "uploaded" => DColor.Accent, _ => DColor.Neutral }; string KindName(string kind) => kind switch { "nightly" => Localizer["Nightly"], "manual" => Localizer["Made by hand"], "pre-deploy" => Localizer["Before a deploy"], "pre-restore" => Localizer["Before a restore"], "uploaded" => Localizer["Uploaded"], _ => kind }; async Task BackUp() { busy = true; var result = await AdminService.BackUp(); busy = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } backups.Running = "backup"; Toast.AddResponseSuccess(Localizer["The backup has started."]); Poll(); } async Task Delete(ViewBackup backup) { if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true)) return; busy = true; var result = await AdminService.DeleteBackup(backup.Id); busy = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)]; } void AskDownload(ViewBackup backup) { (password, ticket, dialogError) = (null, null, null); downloading = backup; } void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null); async Task Download() { busy = true; var result = await AdminService.BackupTicket(downloading.Id, password); busy = false; if (result.IsInvalid) { dialogError = result.ErrorMessage; return; } var granted = (ViewBackupTicket)result.Data; ticket = new Uri(ApiBase, granted.Url).AbsoluteUri; ticketSize = granted.Bytes; password = null; dialogError = null; } void AskRestore(ViewBackup backup) { (password, typedHost, dialogError) = (null, null, null); restoring = backup; } void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null); async Task Restore() { busy = true; var asked = restoring; var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() }); busy = false; if (result.IsInvalid) { dialogError = result.ErrorMessage; return; } CloseRestore(); restarting = asked.Id; Poll(); } Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress); void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null; async Task Upload() { transfer ??= await JS.InvokeAsync("import", "./js/transfer.js"); self ??= DotNetObjectReference.Create(this); var started = await AdminService.StartBackupUpload(); if (started.IsInvalid) { Toast.AddResponseError(started); return; } var upload = (ViewBackupUpload)started.Data; (uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0); var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri; var sent = await transfer.InvokeAsync("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self); if (!sent.Ok) { uploading = false; await AdminService.CancelBackupUpload(upload.Id); Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]); return; } var finished = await AdminService.FinishBackupUpload(upload.Id); uploading = false; if (finished.IsInvalid) { Toast.AddResponseError(finished); return; } var backup = (ViewBackup)finished.Data; backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)]; pickedName = null; Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]); } [JSInvokable] public Task Progress(long received, long total) { (uploaded, uploadSize) = (received, total); uploadedPercent = total == 0 ? 100 : 100.0 * received / total; return InvokeAsync(StateHasChanged); } sealed class UploadOutcome { public bool Ok { get; set; } public int Status { get; set; } public string Error { get; set; } } public async ValueTask DisposeAsync() { polling?.Cancel(); self?.Dispose(); if (transfer is null) return; try { await transfer.DisposeAsync(); } catch (JSDisconnectedException) { } } }