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 /.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 _tickets = new(); readonly ConcurrentDictionary _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); } /// The backup a ticket is for, while it is good. 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; } /// Who started an upload (an administrator's name, or the root a persona's archive belongs to). public string Owner(string id) { if (!IsId(id) || !System.IO.File.Exists(Path.Combine(Folder, id + ".json"))) return default; try { return JsonDocument.Parse(System.IO.File.ReadAllText(Path.Combine(Folder, id + ".json"))).RootElement.GetProperty("by").GetString(); } catch (JsonException) { return default; } } /// A whole upload moved where it is read from: its new path, or null when there is no such upload. public string Take(string id, string folder, string name) { if (!IsId(id) || !System.IO.File.Exists(File(id))) return default; ServerBackup.MakeDirectory(folder); var path = Path.Combine(folder, name); System.IO.File.Move(File(id), path, overwrite: true); System.IO.File.Delete(Path.Combine(Folder, id + ".json")); _appending.TryRemove(id, out _); return path; } /// How much of an upload has arrived; -1 when there is no such upload. public long Received(string id) => IsId(id) && System.IO.File.Exists(File(id)) ? new FileInfo(File(id)).Length : -1; /// A piece appended where the upload stands: what has arrived since, and whether it was taken. 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(); } } /// The whole upload read into a backup: the backup, or why not. The upload goes either way. 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); } } }