Files
SocialPub/PrivaPub/Infrastructure/Backup/TransferStore.cs
T
thepraandClaude Opus 5.5 3d7d406834 A persona's archive: exported in Mastodon's layout, imported without telling anyone
A root exports one of its personas (a job) as Mastodon's account archive, so other servers' importers read it: the
actor with its public key only, its own posts and boosts with their media, likes, bookmarks and Mastodon's CSV files,
plus PrivaPub's filters, followed hashtags, notification policy, pins, scheduled and located posts; nothing of its root,
its siblings, its keys or anyone's token. A ticket link downloads it, for a week.

An archive (PrivaPub's or Mastodon's) uploaded in pieces is imported into a persona in a job, the parts the root picks,
with progress and a stop. SafeArchive refuses links, escaping paths, duplicates, bombs and oversized items, and reads the
outbox one item at a time. Imported posts are delivered to no one, put in no home and notify nobody, yet show on the
profile, outbox, hashtags and search; back home a post keeps its id, from another actor it gets one of its date and
ImportedFromURI, so importing twice changes nothing. Relationships go through the existing services; located, scheduled
and likes only when asked; followers never.

tools/pasture/scenarios/persona-archive.sh imports mastouser's real Mastodon archive (156 posts, 24 pictures) into a
persona Mastodon follows: Mastodon receives none of it, and a second import changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:43:12 +02:00

165 lines
6.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>Who started an upload (an administrator's name, or the root a persona's archive belongs to).</summary>
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;
}
}
/// <summary>A whole upload moved where it is read from: its new path, or null when there is no such upload.</summary>
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;
}
/// <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);
}
}
}