Files

399 lines
18 KiB
C#
Raw Permalink Normal View History

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<ExportArchiveJob> 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<JobOutcome> 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<PersonaArchive>().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<PersonaArchive>().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<PersonaArchive>().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<Avatar>().MatchID(actor.Id).ExecuteFirstAsync(token);
var files = new SortedSet<string>(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<string>() 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<Favourite, string>().Match(f => f.AccountId == actor.Id).Project(f => f.PostId), token), token);
var bookmarks = await Uris(DB.Default.Find<Bookmark, string>().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<Following>().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<PersonaList>().Match(l => l.AvatarId == actor.Id).ExecuteAsync(token);
foreach (var list in lists)
foreach (var member in await DB.Default.Find<PersonaListMember>().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<Block>().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<Mute>().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<AccountDomainBlock, string>().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<string> 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<PostEntity>().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<PostEntity>().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<JsonObject>())
if (attachment["url"]?.GetValue<string>() 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<PersonaFilter>().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<FollowedTag, string>().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<Pin, string>().Match(p => p.AvatarId == actor.Id).Project(p => p.PostId), token), token);
var scheduled = await DB.Default.Find<ScheduledStatus>().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<PostEntity>().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<Dictionary<string, (string Path, string Description)>> MediaFiles(IEnumerable<string> ids, CancellationToken token)
{
var wanted = ids.Distinct().ToList();
if (wanted.Count == 0)
return [];
return (await DB.Default.Find<MediaAttachment>().Match(m => wanted.Contains(m.ID) && m.TrashedAt == null).ExecuteAsync(token))
.ToDictionary(m => m.ID, m => (m.FilePath, m.Description));
}
static async Task<Dictionary<string, string>> UriMap(IEnumerable<string> postIds, CancellationToken token)
{
var ids = postIds.Where(id => id != default).Distinct().ToList();
if (ids.Count == 0)
return [];
return (await DB.Default.Find<PostEntity>().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<List<string>> Uris<T>(Find<T, string> 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<string> 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<T>(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<string, string> _known = new(StringComparer.Ordinal);
public async Task<string> 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<ForeignAvatar>().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;
}
}
}
}