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. *@ +
+
@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["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]
+ } +@Localizer["No backup yet."]
+ } + + @foreach (var backup in backups.Backups) + { ++ @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["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]
+@Localizer["The link works for ten minutes, for this backup only."]
+ + @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)] + + } +@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["Backups"] +
+