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
80 lines
3.3 KiB
C#
80 lines
3.3 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.User;
|
|
|
|
namespace PrivaPub.Domain.Portability
|
|
{
|
|
// A persona's archive, made and restored in jobs (owner decision 2026-10-07): where each stands, one row per persona
|
|
// (its ID is the persona's). The files live in <backups>/.personas/<avatarId>/: the export, kept a week, and the upload
|
|
// being imported.
|
|
public class PersonaArchive : Entity
|
|
{
|
|
public string ExportState { get; set; } = "none";
|
|
public DateTime? ExportAskedAt { get; set; }
|
|
public DateTime? ExportReadyAt { get; set; }
|
|
public string ExportFile { get; set; }
|
|
public long ExportBytes { get; set; }
|
|
public string ExportError { get; set; }
|
|
|
|
public string ImportState { get; set; } = "none";
|
|
public DateTime? ImportAskedAt { get; set; }
|
|
public DateTime? ImportEndedAt { get; set; }
|
|
public string ImportFile { get; set; }
|
|
public List<string> ImportParts { get; set; } = [];
|
|
public long ImportDone { get; set; }
|
|
public long ImportTotal { get; set; }
|
|
public Dictionary<string, int> ImportCounts { get; set; } = [];
|
|
public string ImportError { get; set; }
|
|
public string ImportFrom { get; set; }
|
|
public bool ImportSameActor { get; set; }
|
|
public bool StopAsked { get; set; }
|
|
}
|
|
|
|
public static class PersonaArchives
|
|
{
|
|
public static readonly TimeSpan Kept = TimeSpan.FromDays(7);
|
|
|
|
/// <summary>Every part an import can take; the last three only when asked (they act, or tell others).</summary>
|
|
public static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
|
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
|
|
|
public static string Folder(string backupsRoot, string avatarId) => Path.Combine(backupsRoot, ".personas", avatarId);
|
|
|
|
public static async Task<PersonaArchive> Of(string avatarId, CancellationToken token) =>
|
|
await DB.Default.Find<PersonaArchive>().MatchID(avatarId).ExecuteFirstAsync(token) ?? new PersonaArchive { ID = avatarId };
|
|
|
|
public static ViewPersonaArchive View(PersonaArchive archive) => new()
|
|
{
|
|
ExportState = archive.ExportState,
|
|
ExportAskedAt = archive.ExportAskedAt,
|
|
ExportReadyAt = archive.ExportReadyAt,
|
|
ExportExpiresAt = archive.ExportReadyAt + Kept,
|
|
ExportBytes = archive.ExportBytes,
|
|
ExportError = archive.ExportError,
|
|
ImportState = archive.ImportState,
|
|
ImportAskedAt = archive.ImportAskedAt,
|
|
ImportEndedAt = archive.ImportEndedAt,
|
|
ImportParts = archive.ImportParts,
|
|
ImportDone = archive.ImportDone,
|
|
ImportTotal = archive.ImportTotal,
|
|
ImportCounts = archive.ImportCounts,
|
|
ImportError = archive.ImportError,
|
|
ImportFrom = archive.ImportFrom,
|
|
ImportSameActor = archive.ImportSameActor
|
|
};
|
|
|
|
/// <summary>Exports older than a week go, as do imports' files left behind.</summary>
|
|
public static async Task Forget(string backupsRoot, CancellationToken token)
|
|
{
|
|
var folder = Path.Combine(backupsRoot, ".personas");
|
|
if (!Directory.Exists(folder))
|
|
return;
|
|
var before = DateTime.UtcNow - Kept;
|
|
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => File.GetLastWriteTimeUtc(f) < before))
|
|
File.Delete(file);
|
|
await DB.Default.Update<PersonaArchive>().Match(a => a.ExportState == "ready" && a.ExportReadyAt < before)
|
|
.Modify(a => a.ExportState, "none").Modify(a => a.ExportFile, null).ExecuteAsync(token);
|
|
}
|
|
}
|
|
}
|