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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user