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
This commit is contained in:
1 parent
bdc8be4508
commit
3d7d406834
18 files changed
+2377
-7
No files matched your search
@@ -32,7 +32,7 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
}
|
||||
|
||||
// A nightly backup at Backups:NightlyAt (UTC), or as soon as the service is up after missing it; the old ones rotated
|
||||
// out as each is made.
|
||||
// out as each is made. Personas' archives a week old go too.
|
||||
public class BackupScheduler(Backups backups, ILogger<BackupScheduler> logger) : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
|
||||
@@ -52,6 +52,7 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
try
|
||||
{
|
||||
await RunIfDue(DateTime.UtcNow, stoppingToken);
|
||||
await Domain.Portability.PersonaArchives.Forget(backups.Root, stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -39,7 +39,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
["openiddict.authorizations"] = "sessions: a restore ends every one",
|
||||
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
||||
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
|
||||
[nameof(RestoreRecord)] = "what restores did outlives what they restore"
|
||||
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
|
||||
["PersonaArchive"] = "personas' archives being made or imported belong to the moment"
|
||||
};
|
||||
|
||||
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
|
||||
|
||||
@@ -55,6 +55,34 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
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;
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Post>(token, p => p.ReblogOfPostId);
|
||||
await Plain<Post>(token, p => p.AnsweringToPostId);
|
||||
await DB.Default.Index<Post>().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token);
|
||||
// a post imported from another actor's archive, once per persona: importing it twice changes nothing
|
||||
await DB.Default.Index<Post>().Key(p => p.GroupUserId, KeyType.Ascending).Key(p => p.ImportedFromURI, KeyType.Ascending)
|
||||
.Option(o =>
|
||||
{
|
||||
o.Unique = true;
|
||||
o.PartialFilterExpression = Builders<Post>.Filter.Gt(p => p.ImportedFromURI, "");
|
||||
})
|
||||
.CreateAsync(token);
|
||||
|
||||
// media: a post's, a persona's, what a scheduled post holds, the janitor's never-posted uploads and its trash
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.PostId, m => m.ScheduledStatusId, m => m.CreatedAt);
|
||||
|
||||
Reference in new issue
Block a user