diff --git a/CLAUDE.md b/CLAUDE.md index 6cd3d0a..000bfac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,9 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam - An idle globe draws no frame. Photos and stats go to `tests/e2e/out/globe/`. +- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It + ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other + tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`. - **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests` scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time, DOM nodes, JS and .NET heaps and long tasks to `perf.json`. @@ -379,8 +382,16 @@ supplies: and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import resolves each row through PrivaPub and acts one row at a time; it can be stopped. - **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for - administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users, - and adding an admin endpoint waits for the owner, so the Users section stays empty. + administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list + of users, and adding an admin endpoint waits for the owner, so the Users section stays empty. +- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's + backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report. + - A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar + to disk. + - An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub + has, and carries on from what PrivaPub says after a refused or lost piece. + - A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as + it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in. - **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`): what notifications from people the persona does not follow, people who do not follow it, new accounts, private mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 9f978ed..af3e3a4 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -58,6 +58,12 @@ namespace decePubClient; [JsonSerializable(typeof(ViewStatisticsOverview))] [JsonSerializable(typeof(ViewHostsPage))] [JsonSerializable(typeof(ViewCrawler))] +[JsonSerializable(typeof(ViewBackups))] +[JsonSerializable(typeof(ViewBackup))] +[JsonSerializable(typeof(BackupPasswordForm))] +[JsonSerializable(typeof(RestoreBackupForm))] +[JsonSerializable(typeof(ViewBackupTicket))] +[JsonSerializable(typeof(ViewBackupUpload))] public partial class ClientJsonContext : JsonSerializerContext { } diff --git a/Components/Admin/BackupsPanel.razor b/Components/Admin/BackupsPanel.razor new file mode 100644 index 0000000..f458b02 --- /dev/null +++ b/Components/Admin/BackupsPanel.razor @@ -0,0 +1,448 @@ +@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) + { + } + } +} diff --git a/Models/APIs.cs b/Models/APIs.cs index 1b1c1b4..f1830ee 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -42,6 +42,15 @@ public static class APIs public const string GET_StatisticsOverview = "clientapi/admin/statistics/overview"; public const string GET_StatisticsHosts = "clientapi/admin/statistics/hosts"; public const string GET_StatisticsCrawler = "clientapi/admin/statistics/crawler"; + public const string GET_AdminBackups = "clientapi/admin/backups"; + public const string POST_AdminBackup = "clientapi/admin/backups"; + public static string DELETE_M_AdminBackup(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}"; + public static string POST_M_AdminBackupTicket(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/ticket"; + public static string POST_M_AdminBackupRestore(string id) => $"clientapi/admin/backups/{Uri.EscapeDataString(id)}/restore"; + public const string POST_AdminBackupUpload = "clientapi/admin/backups/uploads"; + public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}"; + public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish"; + public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}"; } public static class OAuth diff --git a/Pages/Administration.razor b/Pages/Administration.razor index 7cc7ca8..ea916f1 100644 --- a/Pages/Administration.razor +++ b/Pages/Administration.razor @@ -4,7 +4,8 @@ @Localizer["Administration"] -@* Moderators handle reports; administrators also block servers for everyone and read the federation statistics. The +@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back + up and restore the server. The other sections wait for PrivaPub endpoints that do not exist yet. *@
@@ -65,6 +66,19 @@ + + +
+

+ @Localizer["Backups"] +

+
+ +
+ +
+
+
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index cac3598..9c70433 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1940,4 +1940,145 @@ "{0}" non è un tipo di file che questo server accetta. + + È in corso un backup. + + + È in corso un ripristino. + + + Fai un backup ora + + + Backup + + + Prima di un rilascio + + + Prima di un ripristino + + + Eliminare questo backup? + + + Eliminati di nuovo: {0} account, {1} persona, {2} gruppi, {3} post. Mantenuti da allora: {4} blocchi, silenziamenti, segnalazioni e altri atti di protezione. + + + Scarica + + + Scarica un backup + + + Scarica {0} + + + Ottieni il link + + + Contiene le chiavi private e i post privati di ogni persona: tienilo dove solo tu puoi leggerlo. La tua password, per avere un link: + + + I suoi file vengono eliminati; il server non viene toccato. + + + Creati dopo il backup, ora eliminati mantenendo i loro nomi: {0}. + + + Fatto a mano + + + Notturno + + + Ancora nessun backup. + + + PrivaPub si sta riavviando per ripristinare {0}. Tutti, tu compreso, accederanno di nuovo quando tornerà. + + + PrivaPub si ferma, ripristina questo backup e riparte, in pochi minuti. Tutti dovranno accedere di nuovo. + + + Ripristina + + + Ripristinare il backup del {0}? + + + Ripristinato {0} + + + Il backup è iniziato. + + + Il backup {0} è stato caricato. + + + L'ultimo ripristino, di {0}, è stato abbandonato; non è cambiato nulla. + + + Il link funziona per dieci minuti, solo per questo backup. + + + Il ripristino di {0}, chiesto da {1}, è {2}. + + + Il caricamento non è riuscito: {0} + + + Scrivi {0} per confermare + + + Carica un backup scaricato da qui (un file .tar): + + + Caricato + + + Quanto fatto da allora va perso: post, modifiche, richieste di seguire, impostazioni. Ciò che protegge no: eliminazioni, blocchi, silenziamenti, server bloccati, segnalazioni, filtri e nomi riservati fatti da allora restano, e i follower restano quelli di adesso. Account, persona e gruppi creati da allora vengono eliminati, mantenendo i loro nomi. + + + chiesto da {0} + + + non letto in un unico istante + + + {0} documenti in {1} collezioni + + + {0} documenti in {1} collezioni; {2} file multimediali recuperati, {3} mancanti. + + + {0} liberi sul disco del server + + + {0} account sono stati eliminati da allora; un ripristino li elimina di nuovo. + + + {0} file multimediali ({1}) + + + {0} file multimediali elencati, conservati dai backup notturni + + + {0} mancanti + + + {0} di {1} + + + {0} post creati da allora risultano eliminati; {1} file sono andati nel cestino; {2} sessioni terminate. Il server com'era prima è il backup {3}. + + + in attesa + + + in corso + + + illeggibile + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 9e0bb70..2cd80ce 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1940,4 +1940,145 @@ "{0}" is not a kind of file this server takes. + + A backup is running. + + + A restore is running. + + + Back up now + + + Backups + + + Before a deploy + + + Before a restore + + + Delete this backup? + + + Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts. + + + Download + + + Download a backup + + + Download {0} + + + Get the link + + + It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link: + + + Its files are deleted; the server itself is untouched. + + + Made after the backup, now deleted with their names kept: {0}. + + + Made by hand + + + Nightly + + + No backup yet. + + + PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back. + + + PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again. + + + Restore + + + Restore the backup of {0}? + + + Restored {0} + + + The backup has started. + + + The backup {0} was uploaded. + + + The last restore, of {0}, was abandoned; nothing changed. + + + The link works for ten minutes, for this backup only. + + + The restore of {0}, asked for by {1}, is {2}. + + + The upload failed: {0} + + + Type {0} to confirm + + + Upload a backup downloaded from here (a .tar file): + + + Uploaded + + + 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. + + + asked for by {0} + + + not read at one instant + + + {0} documents in {1} collections + + + {0} documents in {1} collections; {2} media files brought back, {3} missing. + + + {0} free on the server's disk + + + {0} logins were deleted since; a restore deletes them again. + + + {0} media files ({1}) + + + {0} media files listed, kept by the nightly backups + + + {0} missing + + + {0} of {1} + + + {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}. + + + pending + + + running + + + unreadable + diff --git a/Services/AdminService.cs b/Services/AdminService.cs index 51e01a3..be79e9c 100644 --- a/Services/AdminService.cs +++ b/Services/AdminService.cs @@ -62,6 +62,74 @@ namespace decePubClient.Services return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } + /// The server's backups, what runs and the last restore; null on failure. + public async Task Backups(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_AdminBackups, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync(cancellationToken); + + await LogFailure(response, nameof(Backups), cancellationToken); + return null; + } + + /// Starts a backup; it runs on while the list says so. + public async Task BackUp(CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackup, cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + public async Task DeleteBackup(string id, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackup(id), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + /// A download link for a backup, for the password; Data is the ViewBackupTicket. + public async Task BackupTicket(string id, string password, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupTicket(id), new BackupPasswordForm { Password = password }, + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// Asks for a backup to be restored: PrivaPub stops within seconds and restores it as it starts again. + public async Task RestoreBackup(string id, RestoreBackupForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupRestore(id), form, cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + /// An upload to send a backup's pieces to (transfer.js); Data is the ViewBackupUpload. + public async Task StartBackupUpload(CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_AdminBackupUpload, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// The whole upload read into a backup; Data is the ViewBackup. + public async Task FinishBackupUpload(string id, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminBackupUploadFinish(id), cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + public async Task CancelBackupUpload(string id, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_AdminBackupUpload(id), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + public async Task Overview(int days, CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken); diff --git a/tests/e2e/Actions/BackupsTests.cs b/tests/e2e/Actions/BackupsTests.cs new file mode 100644 index 0000000..98e91cd --- /dev/null +++ b/tests/e2e/Actions/BackupsTests.cs @@ -0,0 +1,138 @@ +using System.Formats.Tar; +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now, + // downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in + // pieces; each step checked on /clientapi + public sealed class BackupsTests(Browser browser) + { + const string Root = PastureAdmin.Root; + static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null); + + internal static async Task Open(IPage page) + { + await page.GotoAsync($"{Pasture.Site}/administration"); + var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First; + await header.ClickAsync(); + var panel = header.Locator("xpath=..").Locator("[data-backups]"); + await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 }); + return panel; + } + + internal static async Task BackUp(ILocator panel) + { + var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).ToHashSet(); + await panel.Locator("[data-backup-now]").ClickAsync(); + var made = default(string); + Assert.True(await Pasture.Eventually(async () => + { + var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!; + made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).FirstOrDefault(id => !before.Contains(id)); + return made is not null && list["running"] is null; + }, 300), "the backup was never made"); + await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 }); + return made; + } + + [Fact] + public async Task A_backup_is_made_downloaded_and_uploaded_again() + { + await using var session = await SignIn.As(browser, Admin); + var page = session.Page; + var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar"); + try + { + await Results.Cell("client.admin.backup", "privapub", Root, async () => + { + var panel = await Open(page); + var id = await BackUp(panel); + var row = panel.Locator($"[data-backup='{id}']"); + await Assertions.Expect(row).ToContainTextAsync("Made by hand"); + + // the link, for the password + await row.Locator("[data-backup-download]").ClickAsync(); + var dialog = page.Locator("[data-backup-download-dialog]"); + await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password); + await dialog.Locator("[data-backup-confirm]").ClickAsync(); + var link = dialog.Locator("[data-backup-link]"); + await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 }); + var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 }); + await download.SaveAsAsync(file); + var names = new List(); + await using (var reader = new TarReader(File.OpenRead(file))) + while (await reader.GetNextEntryAsync() is { } entry) + names.Add(entry.Name); + Assert.Contains($"{id}/manifest.json", names); + Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal)); + await dialog.Locator("[data-backup-dialog-close]").ClickAsync(); + + // deleted (asked first), then uploaded again from the file + await row.Locator("[data-backup-delete]").ClickAsync(); + await page.Locator("[data-confirm-yes]").ClickAsync(); + await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 }); + Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue() == id); + + await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file); + await panel.Locator("[data-backup-upload-start]").ClickAsync(); + await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 }); + var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue() == id)!; + Assert.Equal("uploaded", uploaded["kind"]!.GetValue()); + await session.AssertGuards(); + }, page); + } + finally + { + File.Delete(file); + } + } + } + + // A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session + // ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and + // ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in + // again (town.sh renew privapub). + [Trait("Category", "Restore")] + public sealed class RestoreTests(Browser browser) + { + static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null); + + [Fact] + public async Task A_backup_is_restored_and_everyone_signs_in_again() + { + await using var session = await SignIn.As(browser, Admin); + var page = session.Page; + await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () => + { + var panel = await BackupsTests.Open(page); + var id = await BackupsTests.BackUp(panel); + var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue(); + + await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync(); + var dialog = page.Locator("[data-backup-restore-dialog]"); + await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password); + var confirm = dialog.Locator("[data-backup-confirm]"); + await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test"); + await Assertions.Expect(confirm).ToBeDisabledAsync(); + await dialog.Locator("[data-backup-host]").FillAsync(host); + await confirm.ClickAsync(); + await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 }); + + // once PrivaPub is back, the session it ended sends the page to sign in again + await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 }); + await Pasture.RenewTown(); + }, page); + + await using var again = await SignIn.As(browser, Admin); + var panelAgain = await BackupsTests.Open(again.Page); + await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 }); + Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]); + await again.AssertGuards(); + } + } +} diff --git a/tests/e2e/Support/Pasture.cs b/tests/e2e/Support/Pasture.cs index d70542e..d662e5c 100644 --- a/tests/e2e/Support/Pasture.cs +++ b/tests/e2e/Support/Pasture.cs @@ -57,6 +57,20 @@ namespace decePubClient.E2E.Support return JsonNode.Parse(output.Trim().Split('\n').Last())!; } + // the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session + public static async Task RenewTown() + { + var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" }) + start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"town.sh renew privapub failed: {error}"); + } + // a script on PrivaPub's own database in the pasture, to undo what no API undoes public static async Task Mongo(string script) { diff --git a/wwwroot/js/transfer.js b/wwwroot/js/transfer.js new file mode 100644 index 0000000..6c26269 --- /dev/null +++ b/wwwroot/js/transfer.js @@ -0,0 +1,65 @@ +// Files sent to PrivaPub in pieces, read by the browser and never copied into .NET's memory: a backup can be gigabytes. +// Each piece is a slice of the picked file PUT at the offset PrivaPub has received; a piece refused (409) or lost to the +// network carries on from what PrivaPub says it has, so a broken upload resumes. Progress goes to listener.Progress. + +const attempts = 6; + +function pause(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function received(endpoint, token) { + const response = await fetch(endpoint, { headers: { Authorization: `Bearer ${token}` } }); + if (!response.ok) + throw new Error(`PrivaPub answered ${response.status}`); + return (await response.json()).received; +} + +// the picked file's name and size, or null +export function picked(selector) { + const file = document.querySelector(selector)?.files?.[0]; + return file ? { name: file.name, size: file.size } : null; +} + +// sends the picked file: { ok, size } or { ok: false, status, error } +export async function upload(selector, endpoint, token, chunkBytes, listener) { + const file = document.querySelector(selector)?.files?.[0]; + if (!file) + return { ok: false, status: 0, error: "no file" }; + let offset = 0; + let failures = 0; + while (offset < file.size) { + const end = Math.min(offset + chunkBytes, file.size); + let response; + try { + response = await fetch(`${endpoint}?offset=${offset}`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream" }, + body: file.slice(offset, end) + }); + } catch (error) { + if (++failures >= attempts) + return { ok: false, status: 0, error: String(error) }; + await pause(1000 * 2 ** failures); + try { + offset = await received(endpoint, token); + } catch { + // still away: try the same piece again + } + continue; + } + if (response.ok || response.status === 409) { + const now = (await response.json()).received; + // refused where it already stood: the piece itself is refused, and sending it again changes nothing + if (!response.ok && now === offset && ++failures >= attempts) + return { ok: false, status: 409, error: "the piece was refused" }; + if (now !== offset) + failures = 0; + offset = now; + await listener.invokeMethodAsync("Progress", offset, file.size); + continue; + } + return { ok: false, status: response.status, error: await response.text() }; + } + return { ok: true, size: file.size }; +}