Administration backs the server up, downloads, uploads and restores it

A Backups section (for administrators, PrivaPub's /clientapi/admin/backups): the backups kept with their kind, size,
media and the logins deleted since, what runs, the restore waiting and the last restore's report. Back up now; download
for the password through a link good for ten minutes, which the browser saves to disk; upload a downloaded backup in
pieces straight from the picked file (js/transfer.js PUTs file.slice at the offset PrivaPub has, never through .NET's
memory, and carries on after a refused or lost piece); delete after asking; restore for the password and the host typed
out, saying what it keeps and what it loses, then showing PrivaPub restarting until the ended session sends the page to
sign in.

e2e: a backup made, downloaded and uploaded again; and, only with --filter "Category=Restore", a real restore of the
pasture, which signs the town in again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 12:45:30 +02:00
1 parent 70cb389537
commit dc2190e271
11 files changed
+1058 -3

No files matched your search

+13 -2
View File
@@ -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
+6
View File
@@ -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
{
}
+448
View File
@@ -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. *@
<div class="flex flex-col gap-3 w-full" data-backups>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (restarting is not null)
{
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
</DAlert>
}
else
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
<i class="ion-md-archive"></i> @Localizer["Back up now"]
</DButton>
@if (backups.Running is { } running)
{
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
</span>
}
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
</div>
@if (backups.Waiting is { } waiting)
{
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
<span>
@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)
{
<br /><span class="text-xs">@error</span>
}
</span>
</DAlert>
}
@if (backups.LastRestore is { } last)
{
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
@if (last.Abandoned)
{
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
<p class="text-xs">@last.Error</p>
}
else
{
<p>
<b>@Localizer["Restored {0}", last.Backup]</b>
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
</p>
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
<p class="text-xs">@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]</p>
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
{
<p class="text-xs" data-last-restore-tombstoned>
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
</p>
}
<p class="text-xs opacity-70">@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]</p>
}
</div>
}
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
data-backup-upload />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
</DButton>
</div>
@if (uploading)
{
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
}
</div>
@if (backups.Backups.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
}
@foreach (var backup in backups.Backups)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
</div>
<p class="text-xs opacity-70">
@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)
{
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
}
@if (!backup.Consistent)
{
<span>; @Localizer["not read at one instant"]</span>
}
</p>
@if (backup.RootsDeletedSince > 0)
{
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
<i class="ion-md-download"></i> @Localizer["Download"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
OnClick="() => AskRestore(backup)" data-backup-restore>
<i class="ion-md-refresh"></i> @Localizer["Restore"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
<i class="ion-md-trash"></i> @Localizer["Delete"]
</DButton>
</div>
</div>
}
}
</div>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
CloseText="@Localizer["Close"]" data-backup-download-dialog>
<Heading>@Localizer["Download a backup"]</Heading>
<ChildContent>
@if (ticket is null)
{
<p class="text-sm">@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:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
else
{
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
</a>
}
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
@if (ticket is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
@Localizer["Get the link"]
</DButton>
}
</Actions>
</DModal>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 text-sm">
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
<p>@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."]</p>
@if (restoring?.RootsDeletedSince > 0)
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
<ErrorViewer AdditionalError="@dialogError" />
</div>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error"
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
OnClick="Restore" data-backup-confirm>
@Localizer["Restore"]
</DButton>
</Actions>
</DModal>
@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<BackupsPanel> 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<IJSObjectReference>("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<UploadOutcome>("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)
{
}
}
}
+9
View File
@@ -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
+15 -1
View File
@@ -4,7 +4,8 @@
<Title>@Localizer["Administration"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
@@ -65,6 +66,19 @@
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<BackupsPanel />
</div>
</ChildContent>
</DCollapse>
</AuthorizeView>
<DCollapse Icon="DCollapseIcon.Arrow">
+141
View File
@@ -1940,4 +1940,145 @@
<data name="&quot;{0}&quot; is not a kind of file this server takes." xml:space="preserve">
<value>"{0}" non è un tipo di file che questo server accetta.</value>
</data>
<data name="A backup is running." xml:space="preserve">
<value>È in corso un backup.</value>
</data>
<data name="A restore is running." xml:space="preserve">
<value>È in corso un ripristino.</value>
</data>
<data name="Back up now" xml:space="preserve">
<value>Fai un backup ora</value>
</data>
<data name="Backups" xml:space="preserve">
<value>Backup</value>
</data>
<data name="Before a deploy" xml:space="preserve">
<value>Prima di un rilascio</value>
</data>
<data name="Before a restore" xml:space="preserve">
<value>Prima di un ripristino</value>
</data>
<data name="Delete this backup?" xml:space="preserve">
<value>Eliminare questo backup?</value>
</data>
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
<value>Eliminati di nuovo: {0} account, {1} persona, {2} gruppi, {3} post. Mantenuti da allora: {4} blocchi, silenziamenti, segnalazioni e altri atti di protezione.</value>
</data>
<data name="Download" xml:space="preserve">
<value>Scarica</value>
</data>
<data name="Download a backup" xml:space="preserve">
<value>Scarica un backup</value>
</data>
<data name="Download {0}" xml:space="preserve">
<value>Scarica {0}</value>
</data>
<data name="Get the link" xml:space="preserve">
<value>Ottieni il link</value>
</data>
<data name="It holds every persona&#x27;s private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
<value>Contiene le chiavi private e i post privati di ogni persona: tienilo dove solo tu puoi leggerlo. La tua password, per avere un link:</value>
</data>
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
<value>I suoi file vengono eliminati; il server non viene toccato.</value>
</data>
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
<value>Creati dopo il backup, ora eliminati mantenendo i loro nomi: {0}.</value>
</data>
<data name="Made by hand" xml:space="preserve">
<value>Fatto a mano</value>
</data>
<data name="Nightly" xml:space="preserve">
<value>Notturno</value>
</data>
<data name="No backup yet." xml:space="preserve">
<value>Ancora nessun backup.</value>
</data>
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
<value>PrivaPub si sta riavviando per ripristinare {0}. Tutti, tu compreso, accederanno di nuovo quando tornerà.</value>
</data>
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
<value>PrivaPub si ferma, ripristina questo backup e riparte, in pochi minuti. Tutti dovranno accedere di nuovo.</value>
</data>
<data name="Restore" xml:space="preserve">
<value>Ripristina</value>
</data>
<data name="Restore the backup of {0}?" xml:space="preserve">
<value>Ripristinare il backup del {0}?</value>
</data>
<data name="Restored {0}" xml:space="preserve">
<value>Ripristinato {0}</value>
</data>
<data name="The backup has started." xml:space="preserve">
<value>Il backup è iniziato.</value>
</data>
<data name="The backup {0} was uploaded." xml:space="preserve">
<value>Il backup {0} è stato caricato.</value>
</data>
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
<value>L'ultimo ripristino, di {0}, è stato abbandonato; non è cambiato nulla.</value>
</data>
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
<value>Il link funziona per dieci minuti, solo per questo backup.</value>
</data>
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
<value>Il ripristino di {0}, chiesto da {1}, è {2}.</value>
</data>
<data name="The upload failed: {0}" xml:space="preserve">
<value>Il caricamento non è riuscito: {0}</value>
</data>
<data name="Type {0} to confirm" xml:space="preserve">
<value>Scrivi {0} per confermare</value>
</data>
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
<value>Carica un backup scaricato da qui (un file .tar):</value>
</data>
<data name="Uploaded" xml:space="preserve">
<value>Caricato</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="asked for by {0}" xml:space="preserve">
<value>chiesto da {0}</value>
</data>
<data name="not read at one instant" xml:space="preserve">
<value>non letto in un unico istante</value>
</data>
<data name="{0} documents in {1} collections" xml:space="preserve">
<value>{0} documenti in {1} collezioni</value>
</data>
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
<value>{0} documenti in {1} collezioni; {2} file multimediali recuperati, {3} mancanti.</value>
</data>
<data name="{0} free on the server&#x27;s disk" xml:space="preserve">
<value>{0} liberi sul disco del server</value>
</data>
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
<value>{0} account sono stati eliminati da allora; un ripristino li elimina di nuovo.</value>
</data>
<data name="{0} media files ({1})" xml:space="preserve">
<value>{0} file multimediali ({1})</value>
</data>
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
<value>{0} file multimediali elencati, conservati dai backup notturni</value>
</data>
<data name="{0} missing" xml:space="preserve">
<value>{0} mancanti</value>
</data>
<data name="{0} of {1}" xml:space="preserve">
<value>{0} di {1}</value>
</data>
<data name="{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}." xml:space="preserve">
<value>{0} post creati da allora risultano eliminati; {1} file sono andati nel cestino; {2} sessioni terminate. Il server com'era prima è il backup {3}.</value>
</data>
<data name="pending" xml:space="preserve">
<value>in attesa</value>
</data>
<data name="running" xml:space="preserve">
<value>in corso</value>
</data>
<data name="unreadable" xml:space="preserve">
<value>illeggibile</value>
</data>
</root>
+141
View File
@@ -1940,4 +1940,145 @@
<data name="&quot;{0}&quot; is not a kind of file this server takes." xml:space="preserve">
<value>"{0}" is not a kind of file this server takes.</value>
</data>
<data name="A backup is running." xml:space="preserve">
<value>A backup is running.</value>
</data>
<data name="A restore is running." xml:space="preserve">
<value>A restore is running.</value>
</data>
<data name="Back up now" xml:space="preserve">
<value>Back up now</value>
</data>
<data name="Backups" xml:space="preserve">
<value>Backups</value>
</data>
<data name="Before a deploy" xml:space="preserve">
<value>Before a deploy</value>
</data>
<data name="Before a restore" xml:space="preserve">
<value>Before a restore</value>
</data>
<data name="Delete this backup?" xml:space="preserve">
<value>Delete this backup?</value>
</data>
<data name="Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts." xml:space="preserve">
<value>Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.</value>
</data>
<data name="Download" xml:space="preserve">
<value>Download</value>
</data>
<data name="Download a backup" xml:space="preserve">
<value>Download a backup</value>
</data>
<data name="Download {0}" xml:space="preserve">
<value>Download {0}</value>
</data>
<data name="Get the link" xml:space="preserve">
<value>Get the link</value>
</data>
<data name="It holds every persona&#x27;s private keys and private posts: keep it where only you can read it. Your password, to get a link:" xml:space="preserve">
<value>It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:</value>
</data>
<data name="Its files are deleted; the server itself is untouched." xml:space="preserve">
<value>Its files are deleted; the server itself is untouched.</value>
</data>
<data name="Made after the backup, now deleted with their names kept: {0}." xml:space="preserve">
<value>Made after the backup, now deleted with their names kept: {0}.</value>
</data>
<data name="Made by hand" xml:space="preserve">
<value>Made by hand</value>
</data>
<data name="Nightly" xml:space="preserve">
<value>Nightly</value>
</data>
<data name="No backup yet." xml:space="preserve">
<value>No backup yet.</value>
</data>
<data name="PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back." xml:space="preserve">
<value>PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.</value>
</data>
<data name="PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again." xml:space="preserve">
<value>PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again.</value>
</data>
<data name="Restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="Restore the backup of {0}?" xml:space="preserve">
<value>Restore the backup of {0}?</value>
</data>
<data name="Restored {0}" xml:space="preserve">
<value>Restored {0}</value>
</data>
<data name="The backup has started." xml:space="preserve">
<value>The backup has started.</value>
</data>
<data name="The backup {0} was uploaded." xml:space="preserve">
<value>The backup {0} was uploaded.</value>
</data>
<data name="The last restore, of {0}, was abandoned; nothing changed." xml:space="preserve">
<value>The last restore, of {0}, was abandoned; nothing changed.</value>
</data>
<data name="The link works for ten minutes, for this backup only." xml:space="preserve">
<value>The link works for ten minutes, for this backup only.</value>
</data>
<data name="The restore of {0}, asked for by {1}, is {2}." xml:space="preserve">
<value>The restore of {0}, asked for by {1}, is {2}.</value>
</data>
<data name="The upload failed: {0}" xml:space="preserve">
<value>The upload failed: {0}</value>
</data>
<data name="Type {0} to confirm" xml:space="preserve">
<value>Type {0} to confirm</value>
</data>
<data name="Upload a backup downloaded from here (a .tar file):" xml:space="preserve">
<value>Upload a backup downloaded from here (a .tar file):</value>
</data>
<data name="Uploaded" xml:space="preserve">
<value>Uploaded</value>
</data>
<data name="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." xml:space="preserve">
<value>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.</value>
</data>
<data name="asked for by {0}" xml:space="preserve">
<value>asked for by {0}</value>
</data>
<data name="not read at one instant" xml:space="preserve">
<value>not read at one instant</value>
</data>
<data name="{0} documents in {1} collections" xml:space="preserve">
<value>{0} documents in {1} collections</value>
</data>
<data name="{0} documents in {1} collections; {2} media files brought back, {3} missing." xml:space="preserve">
<value>{0} documents in {1} collections; {2} media files brought back, {3} missing.</value>
</data>
<data name="{0} free on the server&#x27;s disk" xml:space="preserve">
<value>{0} free on the server's disk</value>
</data>
<data name="{0} logins were deleted since; a restore deletes them again." xml:space="preserve">
<value>{0} logins were deleted since; a restore deletes them again.</value>
</data>
<data name="{0} media files ({1})" xml:space="preserve">
<value>{0} media files ({1})</value>
</data>
<data name="{0} media files listed, kept by the nightly backups" xml:space="preserve">
<value>{0} media files listed, kept by the nightly backups</value>
</data>
<data name="{0} missing" xml:space="preserve">
<value>{0} missing</value>
</data>
<data name="{0} of {1}" xml:space="preserve">
<value>{0} of {1}</value>
</data>
<data name="{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}." xml:space="preserve">
<value>{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}.</value>
</data>
<data name="pending" xml:space="preserve">
<value>pending</value>
</data>
<data name="running" xml:space="preserve">
<value>running</value>
</data>
<data name="unreadable" xml:space="preserve">
<value>unreadable</value>
</data>
</root>
+68
View File
@@ -62,6 +62,74 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The server's backups, what runs and the last restore; null on failure.</summary>
public async Task<ViewBackups> Backups(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminBackups, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewBackups>(cancellationToken);
await LogFailure(response, nameof(Backups), cancellationToken);
return null;
}
/// <summary>Starts a backup; it runs on while the list says so.</summary>
public async Task<WebResult> 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<WebResult> 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);
}
/// <summary>A download link for a backup, for the password; Data is the ViewBackupTicket.</summary>
public async Task<WebResult> 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<ViewBackupTicket>(cancellationToken) };
}
/// <summary>Asks for a backup to be restored: PrivaPub stops within seconds and restores it as it starts again.</summary>
public async Task<WebResult> 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);
}
/// <summary>An upload to send a backup's pieces to (transfer.js); Data is the ViewBackupUpload.</summary>
public async Task<WebResult> 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<ViewBackupUpload>(cancellationToken) };
}
/// <summary>The whole upload read into a backup; Data is the ViewBackup.</summary>
public async Task<WebResult> 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<ViewBackup>(cancellationToken) };
}
public async Task<WebResult> 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<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
+138
View File
@@ -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<ILocator> 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<string> BackUp(ILocator panel)
{
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).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<string>()).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<string>();
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<string>() == 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<string>() == id)!;
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
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<string>();
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();
}
}
}
+14
View File
@@ -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)
{
+65
View File
@@ -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 };
}