The administrator's page backs up, downloads, uploads and restores

/clientapi/admin/backups (owner decision 2026-10-07, approving these endpoints in production): the list with what runs,
the restore waiting and the last restore's report; back up now; delete; a download for the password, through a ticket
good for ten minutes in the link's path, as one tar whose length is known first and which honours Range (BackupTar);
an upload in pieces of at most 32 MB, each at the offset already received or refused with it, so a broken upload
resumes, read into a backup only when it holds nothing but plain files under one backup's folder; and a restore for
the password and the host typed out. Every call checks the administrator against the database. nginx streams downloads
for an hour and takes upload pieces unbuffered, rate-limited.

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:01:03 +02:00
1 parent fba57318fa
commit bc5f2beaf7
8 files changed
+987

No files matched your search

@@ -0,0 +1,136 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
namespace PrivaPub.Infrastructure.Backup
{
// Backups leaving and arriving through the administrator's page, never through the client's memory:
// - a download is a link with a ticket in its path, good for ten minutes for one backup (a browser downloads it, and
// resumes it with Range), given for the administrator's password;
// - an upload arrives in pieces appended at the offset already received (a piece that would leave a gap or overlap is
// refused with what was received, so a broken upload resumes), into <backups>/.uploads, and is read into a backup
// once whole. One left for a day is deleted.
public class TransferStore(Backups backups)
{
public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(10);
public const long ChunkBytes = 32L * 1024 * 1024;
const string Uploads = ".uploads";
readonly ConcurrentDictionary<string, (string Backup, DateTime Expires)> _tickets = new();
readonly ConcurrentDictionary<string, SemaphoreSlim> _appending = new();
public (string Ticket, DateTime Expires) Ticket(string backup)
{
foreach (var (ticket, stale) in _tickets.Where(t => t.Value.Expires < DateTime.UtcNow).ToList())
_tickets.TryRemove(ticket, out _);
var expires = DateTime.UtcNow + TicketLifetime;
var token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
_tickets[token] = (backup, expires);
return (token, expires);
}
/// <summary>The backup a ticket is for, while it is good.</summary>
public string Redeem(string ticket) =>
ticket != default && _tickets.TryGetValue(ticket, out var held) && held.Expires > DateTime.UtcNow ? held.Backup : default;
string Folder => Path.Combine(backups.Root, Uploads);
string File(string id) => Path.Combine(Folder, id + ".tar");
static bool IsId(string id) => id is { Length: 32 } && id.All(char.IsAsciiHexDigitLower);
public string Start(string by)
{
ServerBackup.MakeDirectory(backups.Root);
ServerBackup.MakeDirectory(Folder);
Forget(DateTime.UtcNow.AddDays(-1));
var id = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (OperatingSystem.IsWindows()
? new FileStream(File(id), FileMode.CreateNew, FileAccess.Write)
: new FileStream(File(id), new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write,
UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead }))
{
}
System.IO.File.WriteAllText(Path.Combine(Folder, id + ".json"), JsonSerializer.Serialize(new { by, at = DateTime.UtcNow }));
return id;
}
/// <summary>How much of an upload has arrived; -1 when there is no such upload.</summary>
public long Received(string id) => IsId(id) && System.IO.File.Exists(File(id)) ? new FileInfo(File(id)).Length : -1;
/// <summary>A piece appended where the upload stands: what has arrived since, and whether it was taken.</summary>
public async Task<(long Received, bool Taken)> Append(string id, long offset, Stream piece, CancellationToken token)
{
if (!IsId(id) || !System.IO.File.Exists(File(id)))
return (-1, false);
var gate = _appending.GetOrAdd(id, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(token);
try
{
await using var file = new FileStream(File(id), FileMode.Open, FileAccess.Write);
if (file.Length != offset)
return (file.Length, false);
file.Seek(offset, SeekOrigin.Begin);
var buffer = new byte[81920];
var total = 0L;
int read;
while ((read = await piece.ReadAsync(buffer, token)) > 0)
{
total += read;
if (total > ChunkBytes)
{
file.SetLength(offset);
return (offset, false);
}
await file.WriteAsync(buffer.AsMemory(0, read), token);
}
await file.FlushAsync(token);
return (file.Length, true);
}
catch (IOException)
{
//the piece broke off: what arrived of it is dropped, so the next one starts where this one did
await using var file = new FileStream(File(id), FileMode.Open, FileAccess.Write);
file.SetLength(offset);
return (offset, false);
}
finally
{
gate.Release();
}
}
/// <summary>The whole upload read into a backup: the backup, or why not. The upload goes either way.</summary>
public async Task<(BackupInfo Backup, string Error)> Finish(string id, CancellationToken token)
{
if (!IsId(id) || !System.IO.File.Exists(File(id)))
return (default, "no such upload");
try
{
await using var tar = new FileStream(File(id), FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan);
return await BackupTar.Import(tar, backups.Root, token);
}
finally
{
Cancel(id);
}
}
public bool Cancel(string id)
{
if (!IsId(id) || !System.IO.File.Exists(File(id)))
return false;
System.IO.File.Delete(File(id));
System.IO.File.Delete(Path.Combine(Folder, id + ".json"));
_appending.TryRemove(id, out _);
return true;
}
// uploads left behind
void Forget(DateTime before)
{
foreach (var file in Directory.EnumerateFiles(Folder).Where(f => System.IO.File.GetLastWriteTimeUtc(f) < before))
System.IO.File.Delete(file);
}
}
}