/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
137 lines
5.1 KiB
C#
137 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|