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
@@ -0,0 +1,399 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user