using MongoDB.Entities; using PrivaPub.Domain.Media; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Infrastructure.Backup; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Models.Media; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using System.IO.Compression; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Portability { // A persona's archive, in Mastodon's account-archive layout so other servers' importers read it: // - actor.json (as other servers see the persona: its public key, never the private one), avatar and header; // - outbox.json: every post of its own as a Create (any visibility but circles' and located posts, addressed as it was // delivered) and its boosts as Announces; the media its posts hold under media_attachments/files/, which the // attachments' urls name; // - likes.json, bookmarks.json, and the CSV files Mastodon exports: follows, lists, blocks, mutes, blocked servers, // bookmarks; // - privapub/: what only PrivaPub has: filters, followed hashtags, the notification policy, pinned posts, scheduled and // located posts, and archive.json saying whose it is. // Nothing of its root, its siblings, its keys or anyone's tokens. Written whole to a .partial file, then renamed; kept a // week (PersonaArchives.Forget). public class ExportArchiveJob(ILocalActorService localActors, IMediaService media, Backups backups, ILogger logger) : IJobHandler { public const int Format = 1; static readonly JsonSerializerOptions Options = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; static readonly JsonWriterOptions Writer = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; const string MediaFolder = "media_attachments/files/"; public JobKind Kind => JobKind.ExportArchive; public int Concurrency => 1; public int MaxAttempts => 2; public int PerHostLimit => 1; public static Job JobFor(string avatarId, string host) => new() { Kind = JobKind.ExportArchive, Payload = avatarId, Host = host, DedupeKey = $"export|{avatarId}|{DateTime.UtcNow.Ticks}" }; public async Task Handle(Job job, CancellationToken token) { var avatarId = job.Payload; var actor = await localActors.FindById(LocalActorKind.Person, avatarId, token); if (actor == default) return JobOutcome.Done; await DB.Default.Update().MatchID(avatarId).Modify(a => a.ExportState, "running").Modify(a => a.ExportError, null).ExecuteAsync(token); var folder = PersonaArchives.Folder(backups.Root, avatarId); ServerBackup.MakeDirectory(Path.GetDirectoryName(folder)!); ServerBackup.MakeDirectory(folder); var name = $"{actor.UserName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip"; var partial = Path.Combine(folder, name + ServerBackup.PartialSuffix); try { await using (var file = new FileStream(partial, FileMode.Create, FileAccess.Write)) using (var zip = new ZipArchive(file, ZipArchiveMode.Create)) await Write(zip, actor, token); var final = Path.Combine(folder, name); File.Move(partial, final); foreach (var old in Directory.EnumerateFiles(folder, "*.zip").Where(f => f != final)) File.Delete(old); await DB.Default.Update().MatchID(avatarId) .Modify(a => a.ExportState, "ready").Modify(a => a.ExportReadyAt, DateTime.UtcNow) .Modify(a => a.ExportFile, name).Modify(a => a.ExportBytes, new FileInfo(final).Length).ExecuteAsync(token); return JobOutcome.Done; } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "The archive of {Persona} could not be made", actor.Handle); if (File.Exists(partial)) File.Delete(partial); await DB.Default.Update().MatchID(avatarId).Modify(a => a.ExportState, "failed") .Modify(a => a.ExportError, ex.Message).ExecuteAsync(CancellationToken.None); return JobOutcome.Done; } } async Task Write(ZipArchive zip, LocalActor actor, CancellationToken token) { var avatar = await DB.Default.Find().MatchID(actor.Id).ExecuteFirstAsync(token); var files = new SortedSet(StringComparer.Ordinal); var prefix = media.Url(string.Empty); var document = ActivityPubRenderer.Actor(actor); foreach (var (field, kind) in new[] { ("icon", "avatar"), ("image", "header") }) if (document[field] is JsonObject image && image["url"]?.GetValue() is { } url && url.StartsWith(prefix, StringComparison.Ordinal)) { var relative = url[prefix.Length..]; var local = Path.Combine(media.Root, relative); if (!File.Exists(local)) continue; var entry = kind + Path.GetExtension(relative); await Copy(zip, local, entry, token); image["url"] = entry; } await Text(zip, "actor.json", document.ToJsonString(Options), token); var counts = await Outbox(zip, actor, prefix, files, token); foreach (var relative in files) { var local = Path.Combine(media.Root, relative); if (File.Exists(local)) await Copy(zip, local, MediaFolder + relative, token); } var handles = new Handles(localActors); await Collection(zip, "likes.json", await Uris(DB.Default.Find().Match(f => f.AccountId == actor.Id).Project(f => f.PostId), token), token); var bookmarks = await Uris(DB.Default.Find().Match(b => b.AvatarId == actor.Id).Project(b => b.PostId), token); await Collection(zip, "bookmarks.json", bookmarks, token); await Text(zip, "bookmarks.csv", string.Concat(bookmarks.Select(b => b + "\n")), token); var follows = await DB.Default.Find().Match(f => f.AvatarId == actor.Id).ExecuteAsync(token); var csv = new StringBuilder("Account address,Show boosts,Notify on new posts,Languages\n"); foreach (var follow in follows) csv.Append($"{Csv(await handles.Of(follow.TargetActorURI, token))},{(follow.ShowReblogs ? "true" : "false")},false,\n"); await Text(zip, "following_accounts.csv", csv.ToString(), token); csv.Clear(); var lists = await DB.Default.Find().Match(l => l.AvatarId == actor.Id).ExecuteAsync(token); foreach (var list in lists) foreach (var member in await DB.Default.Find().Match(m => m.ListId == list.ID).ExecuteAsync(token)) if (follows.FirstOrDefault(f => f.TargetAccountId == member.AccountId) is { } followed) csv.Append($"{Csv(list.Title)},{Csv(await handles.Of(followed.TargetActorURI, token))}\n"); await Text(zip, "lists.csv", csv.ToString(), token); csv.Clear(); foreach (var block in await DB.Default.Find().Match(b => b.AvatarId == actor.Id).ExecuteAsync(token)) csv.Append(Csv(await handles.Of(block.TargetActorURI, token)) + "\n"); await Text(zip, "blocked_accounts.csv", csv.ToString(), token); csv.Clear().Append("Account address,Hide notifications\n"); foreach (var mute in await DB.Default.Find().Match(m => m.AvatarId == actor.Id).ExecuteAsync(token)) csv.Append($"{Csv(await handles.Of(mute.TargetActorURI, token))},{(mute.HideNotifications ? "true" : "false")}\n"); await Text(zip, "muted_accounts.csv", csv.ToString(), token); csv.Clear(); foreach (var domain in await DB.Default.Find().Match(d => d.AvatarId == actor.Id).Project(d => d.Domain).ExecuteAsync(token)) csv.Append(domain + "\n"); await Text(zip, "blocked_domains.csv", csv.ToString(), token); await PrivaPubParts(zip, actor, avatar, prefix, counts, token); } // the outbox, a post at a time, newest first as Mastodon writes it: what was published and boosted async Task<(int Posts, int Boosts, int Located)> Outbox(ZipArchive zip, LocalActor actor, string prefix, ISet files, CancellationToken token) { var entry = zip.CreateEntry("outbox.json", CompressionLevel.Optimal); await using var stream = entry.Open(); await using var writer = new Utf8JsonWriter(stream, Writer); writer.WriteStartObject(); writer.WriteString("@context", "https://www.w3.org/ns/activitystreams"); writer.WriteString("id", "outbox.json"); writer.WriteString("type", "OrderedCollection"); writer.WritePropertyName("orderedItems"); writer.WriteStartArray(); var (posts, boosts) = (0, 0); var located = 0; var before = default(string); while (true) { var find = DB.Default.Find().Match(p => p.GroupUserId == actor.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue); if (before != default) find = find.Match(f => f.Lt(p => p.ID, before)); var batch = await find.Sort(p => p.ID, Order.Descending).Limit(200).ExecuteAsync(token); if (batch.Count == 0) break; before = batch[^1].ID; var originals = (await DB.Default.Find().Match(p => batch.Select(b => b.ReblogOfPostId).Contains(p.ID)).ExecuteAsync(token)) .ToDictionary(p => p.ID); foreach (var post in batch) { if (post.Visibility == PostVisibility.LocalGeo) { located++; continue; } if (post.Visibility == PostVisibility.Circle) continue; if (post.ReblogOfPostId != default) { if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || string.IsNullOrEmpty(original.ObjectURI)) continue; new JsonObject { ["id"] = actor.ActivityUri($"announce-{post.ID}"), ["type"] = "Announce", ["actor"] = actor.Uri, ["published"] = ActivityPubRenderer.Timestamp(post.CreationDate), ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["cc"] = new JsonArray(actor.Followers), ["object"] = original.ObjectURI }.WriteTo(writer); boosts++; continue; } var group = post.GroupId == default ? default : await localActors.FindById(LocalActorKind.Group, post.GroupId, token); var note = ActivityPubRenderer.Note(post, actor, group, post.InReplyToURI); if (post.Visibility is PostVisibility.Direct or PostVisibility.FollowersOnly && (post.To.Count > 0 || post.Cc.Count > 0)) { note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()); note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()); } if (note["attachment"] is JsonArray attachments) foreach (var attachment in attachments.OfType()) if (attachment["url"]?.GetValue() is { } url && url.StartsWith(prefix, StringComparison.Ordinal)) { var relative = url[prefix.Length..]; files.Add(relative); attachment["url"] = "/" + MediaFolder + relative; } var create = ActivityPubRenderer.Create(actor, note, $"create-{post.ID}"); create["to"] = note["to"]?.DeepClone(); create["cc"] = note["cc"]?.DeepClone(); create.WriteTo(writer); posts++; } await writer.FlushAsync(token); } writer.WriteEndArray(); writer.WriteNumber("totalItems", posts + boosts); writer.WriteEndObject(); return (posts, boosts, located); } async Task PrivaPubParts(ZipArchive zip, LocalActor actor, Avatar avatar, string prefix, (int Posts, int Boosts, int Located) counts, CancellationToken token) { var filters = await DB.Default.Find().Match(f => f.AvatarId == actor.Id).ExecuteAsync(token); var filtered = await UriMap(filters.SelectMany(f => f.Statuses.Select(s => s.PostId)), token); await Json(zip, "privapub/filters.json", filters.Select(f => new { title = f.Title, context = f.Context, expiresAt = f.ExpiresAt, action = f.Action, keywords = f.Keywords.Select(k => new { keyword = k.Keyword, wholeWord = k.WholeWord }), statuses = f.Statuses.Select(s => filtered.GetValueOrDefault(s.PostId)).Where(u => u != default) }), token); await Json(zip, "privapub/followed_tags.json", await DB.Default.Find().Match(t => t.AvatarId == actor.Id).Project(t => t.Name).ExecuteAsync(token), token); var policy = await NotificationPolicies.Of(actor.Id, token); await Json(zip, "privapub/notification_policy.json", new { forNotFollowing = policy.ForNotFollowing.ToString(), forNotFollowers = policy.ForNotFollowers.ToString(), forNewAccounts = policy.ForNewAccounts.ToString(), forPrivateMentions = policy.ForPrivateMentions.ToString(), forLimitedAccounts = policy.ForLimitedAccounts.ToString() }, token); await Json(zip, "privapub/pins.json", await Uris(DB.Default.Find().Match(p => p.AvatarId == actor.Id).Project(p => p.PostId), token), token); var scheduled = await DB.Default.Find().Match(s => s.AvatarId == actor.Id).ExecuteAsync(token); var scheduledMedia = await MediaFiles(scheduled.SelectMany(s => s.Params.MediaIds), token); foreach (var file in scheduledMedia.Values) await CopyMedia(zip, file.Path, token); await Json(zip, "privapub/scheduled.json", scheduled.Select(s => new { scheduledAt = s.ScheduledAt, text = s.Params.Text, spoilerText = s.Params.SpoilerText, sensitive = s.Params.Sensitive, visibility = s.Params.Visibility, language = s.Params.Language, poll = s.Params.Poll, media = s.Params.MediaIds.Where(scheduledMedia.ContainsKey).Select(id => new { url = MediaFolder + scheduledMedia[id].Path, description = scheduledMedia[id].Description }) }), token); var located = await DB.Default.Find().Match(p => p.GroupUserId == actor.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.LocalGeo).ExecuteAsync(token); foreach (var post in located) foreach (var item in post.Media.Where(m => m.URL?.StartsWith(prefix, StringComparison.Ordinal) == true)) await CopyMedia(zip, item.URL[prefix.Length..], token); await Json(zip, "privapub/located.json", located.Select(p => new { id = p.ObjectURI, published = p.CreationDate, title = p.Title, text = p.Text, spoilerText = p.SpoilerText, latitude = p.Geo?.Coordinates[1], longitude = p.Geo?.Coordinates[0], rangeKm = p.RangeKm, media = p.Media.Where(m => m.URL?.StartsWith(prefix, StringComparison.Ordinal) == true) .Select(m => new { url = MediaFolder + m.URL[prefix.Length..], description = m.Description }) }), token); await Json(zip, "privapub/archive.json", new { format = Format, software = "privapub", actor = actor.Uri, username = avatar?.UserName ?? actor.UserName, exportedAt = DateTime.UtcNow, posts = counts.Posts, boosts = counts.Boosts, located = counts.Located, scheduled = scheduled.Count }, token); } async Task CopyMedia(ZipArchive zip, string relative, CancellationToken token) { var local = Path.Combine(media.Root, relative); if (File.Exists(local) && zip.GetEntry(MediaFolder + relative) == default) await Copy(zip, local, MediaFolder + relative, token); } static async Task> MediaFiles(IEnumerable ids, CancellationToken token) { var wanted = ids.Distinct().ToList(); if (wanted.Count == 0) return []; return (await DB.Default.Find().Match(m => wanted.Contains(m.ID) && m.TrashedAt == null).ExecuteAsync(token)) .ToDictionary(m => m.ID, m => (m.FilePath, m.Description)); } static async Task> UriMap(IEnumerable postIds, CancellationToken token) { var ids = postIds.Where(id => id != default).Distinct().ToList(); if (ids.Count == 0) return []; return (await DB.Default.Find().Match(p => ids.Contains(p.ID)).Project(p => new PostEntity { ID = p.ID, ObjectURI = p.ObjectURI }).ExecuteAsync(token)) .Where(p => !string.IsNullOrEmpty(p.ObjectURI)).ToDictionary(p => p.ID, p => p.ObjectURI); } static async Task> Uris(Find postIds, CancellationToken token) where T : IEntity { var ids = await postIds.ExecuteAsync(token); var map = await UriMap(ids, token); return ids.Where(map.ContainsKey).Select(id => map[id]).ToList(); } static async Task Collection(ZipArchive zip, string name, List items, CancellationToken token) => await Text(zip, name, new JsonObject { ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = name, ["type"] = "OrderedCollection", ["totalItems"] = items.Count, ["orderedItems"] = new JsonArray(items.Select(i => (JsonNode)i).ToArray()) }.ToJsonString(Options), token); static Task Json(ZipArchive zip, string name, T value, CancellationToken token) => Text(zip, name, JsonSerializer.Serialize(value, Options), token); static async Task Text(ZipArchive zip, string name, string text, CancellationToken token) { await using var stream = zip.CreateEntry(name, CompressionLevel.Optimal).Open(); await stream.WriteAsync(Encoding.UTF8.GetBytes(text), token); } static async Task Copy(ZipArchive zip, string path, string name, CancellationToken token) { await using var source = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan); await using var target = zip.CreateEntry(name, CompressionLevel.NoCompression).Open(); await source.CopyToAsync(target, token); } // a CSV cell, quoted when it must be static string Csv(string value) => value.IndexOfAny([',', '"', '\n', '\r']) < 0 ? value : "\"" + value.Replace("\"", "\"\"") + "\""; // an account as Mastodon's CSV files name it: user@host, or its address when the handle is not known sealed class Handles(ILocalActorService localActors) { readonly Dictionary _known = new(StringComparer.Ordinal); public async Task Of(string actorUri, CancellationToken token) { if (_known.TryGetValue(actorUri, out var handle)) return handle; if (await localActors.FindByUri(actorUri, token) is { } local) handle = local.Handle; else if (await DB.Default.Find().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token) is { UserName.Length: > 0 } remote && Uri.TryCreate(actorUri, UriKind.Absolute, out var parsed)) handle = $"{remote.UserName}@{(string.IsNullOrEmpty(remote.Domain) ? parsed.Authority : remote.Domain)}"; else handle = actorUri; return _known[actorUri] = handle; } } } }