using Microsoft.AspNetCore.Http;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Controllers;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Relationships;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Infrastructure.Ids;
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.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Portability
{
// An archive restored into one of a root's personas (owner decision 2026-10-07): PrivaPub's own, or Mastodon's account
// archive. Only the parts asked for, one at a time, with progress, until asked to stop.
// Posts: only the archive actor's own Creates (boosts, direct and circle posts are counted, not imported); delivered to
// no one, put in no home, notifying nobody, they show on the profile, the outbox, hashtags and search. From the same
// actor (a persona's own archive, back home) a post keeps its id and address, and one already here (even deleted) or
// whose address is a tombstone is left alone; from another actor it gets an id of its date and its address here, with
// the original in ImportedFromURI, so importing twice changes nothing. Replies and self-quotes inside the archive point
// at their copies; polls are closed, with no votes; mentions stay links; the HTML is sanitized as any remote post's;
// media go through MediaService, its checks and the root's quota.
// The rest: follows asked again (not on a blocked server), blocks, mutes, blocked servers, lists (with only accounts
// followed), bookmarks (read by signed fetch), filters, followed hashtags, the notification policy, pins (not
// announced), the profile; and only when asked, located and scheduled posts (PrivaPub's archives) and likes, which tell
// their authors. Followers are never imported: they choose.
public partial class ImportArchiveJob(IServiceScopeFactory scopes, ILocalActorService localActors, IMediaService media, IDomainBlocks domainBlocks,
IJobQueue jobs, ILogger logger) : IJobHandler
{
static readonly HashSet Public = ["https://www.w3.org/ns/activitystreams#Public", "as:Public", "Public"];
static readonly HashSet Postable = ["Note", "Article", "Page", "Question"];
const int MaxMedia = 4;
public JobKind Kind => JobKind.ImportArchive;
public int Concurrency => 1;
public int MaxAttempts => 1;
public int PerHostLimit => 1;
public static Job JobFor(string avatarId, string host) => new()
{
Kind = JobKind.ImportArchive,
Payload = avatarId,
Host = host,
DedupeKey = $"import|{avatarId}|{DateTime.UtcNow.Ticks}"
};
sealed class Run(PersonaArchive state, LocalActor me, SafeArchive archive, string archiveActor, string archiveFollowers, bool sameActor,
IServiceProvider services)
{
public PersonaArchive State { get; } = state;
public string ArchiveFollowers { get; } = archiveFollowers;
public LocalActor Me { get; } = me;
public SafeArchive Archive { get; } = archive;
public string ArchiveActor { get; } = archiveActor;
public bool SameActor { get; } = sameActor;
public IServiceProvider Services { get; } = services;
public Dictionary Posts { get; } = new(StringComparer.Ordinal);//archive object id → here
public Dictionary Parents { get; } = new(StringComparer.Ordinal);//archive object id → what it answers
public DateTime LastSaved { get; set; }
public void Count(string what, int by = 1) => State.ImportCounts[what] = State.ImportCounts.GetValueOrDefault(what) + by;
}
public async Task Handle(Job job, CancellationToken token)
{
var state = await DB.Default.Find().MatchID(job.Payload).ExecuteFirstAsync(token);
if (state is not { ImportState: "queued" or "running" })
return JobOutcome.Done;
var me = await localActors.FindById(LocalActorKind.Person, job.Payload, token);
var path = state.ImportFile;
try
{
if (me == default || !await RootMayImport(me.Id, token))
{
await End(state, "failed", "the persona or its login can no longer import", token);
return JobOutcome.Done;
}
var (archive, refused) = path == default ? (default, "no archive") : SafeArchive.Open(path);
if (archive == default)
{
await End(state, "failed", refused, token);
return JobOutcome.Done;
}
using (archive)
{
var actor = archive.Json("actor.json") as JsonObject;
var actorId = Text(actor?["id"]);
if (actorId == default)
{
await End(state, "failed", "the archive has no actor.json", token);
return JobOutcome.Done;
}
using var scope = scopes.CreateScope();
var run = new Run(state, me, archive, actorId, Text(actor["followers"]) ?? actorId.TrimEnd('/') + "/followers", actorId == me.Uri,
scope.ServiceProvider);
state.ImportState = "running";
state.ImportFrom = actorId;
state.ImportSameActor = run.SameActor;
state.ImportCounts = [];
await Save(run, token, force: true);
var parts = state.ImportParts.ToHashSet(StringComparer.Ordinal);
var privapub = Text((archive.Json("privapub/archive.json") as JsonObject)?["software"]) == "privapub";
if (parts.Contains("profile") && !await Stopped(run, token))
await Profile(run, actor, token);
if (parts.Contains("posts") && !await Stopped(run, token))
await Posts(run, token);
else
await KnownPosts(run, token);
if (parts.Contains("located") && privapub && !await Stopped(run, token))
await Located(run, token);
if (parts.Contains("scheduled") && privapub && !await Stopped(run, token))
await Scheduled(run, token);
if (parts.Contains("tags") && !await Stopped(run, token))
await Tags(run, token);
if (parts.Contains("notificationpolicy") && !await Stopped(run, token))
await Policy(run, token);
if (parts.Contains("filters") && !await Stopped(run, token))
await Filters(run, token);
if (parts.Contains("pins") && !await Stopped(run, token))
await Pins(run, token);
if (parts.Contains("domainblocks") && !await Stopped(run, token))
await DomainBlocks(run, token);
if (parts.Contains("blocks") && !await Stopped(run, token))
await Accounts(run, "blocked_accounts.csv", "blocks", Block, token);
if (parts.Contains("mutes") && !await Stopped(run, token))
await Accounts(run, "muted_accounts.csv", "mutes", Mute, token);
if (parts.Contains("follows") && !await Stopped(run, token))
await Accounts(run, "following_accounts.csv", "follows", Follow, token);
if (parts.Contains("lists") && !await Stopped(run, token))
await Lists(run, token);
if (parts.Contains("bookmarks") && !await Stopped(run, token))
await Bookmarks(run, token);
if (parts.Contains("likes") && !await Stopped(run, token))
await Likes(run, token);
await End(state, await Stopped(run, token) ? "stopped" : "done", default, token);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "The import into {Persona} failed", me?.Handle ?? job.Payload);
await End(state, "failed", ex.Message, CancellationToken.None);
}
finally
{
if (path != default && File.Exists(path))
File.Delete(path);
}
return JobOutcome.Done;
}
static async Task RootMayImport(string avatarId, CancellationToken token)
{
var link = await DB.Default.Find().Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
var root = link == default ? default : await DB.Default.Find().MatchID(link.RootId).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null };
}
static async Task End(PersonaArchive state, string how, string error, CancellationToken token) =>
await DB.Default.Update().MatchID(state.ID)
.Modify(a => a.ImportState, how).Modify(a => a.ImportError, error).Modify(a => a.ImportEndedAt, DateTime.UtcNow)
.Modify(a => a.ImportCounts, state.ImportCounts).Modify(a => a.ImportDone, state.ImportDone).Modify(a => a.ImportTotal, state.ImportTotal)
.Modify(a => a.ImportFile, null).Modify(a => a.StopAsked, false)
.ExecuteAsync(token);
// progress written at most every two seconds; the answer to whether the root asked to stop
static async Task Save(Run run, CancellationToken token, bool force = false)
{
if (!force && DateTime.UtcNow - run.LastSaved < TimeSpan.FromSeconds(2))
return;
run.LastSaved = DateTime.UtcNow;
await DB.Default.Update().MatchID(run.State.ID)
.Modify(a => a.ImportState, run.State.ImportState).Modify(a => a.ImportFrom, run.State.ImportFrom)
.Modify(a => a.ImportSameActor, run.State.ImportSameActor).Modify(a => a.ImportCounts, run.State.ImportCounts)
.Modify(a => a.ImportDone, run.State.ImportDone).Modify(a => a.ImportTotal, run.State.ImportTotal)
.ExecuteAsync(token);
}
static async Task Stopped(Run run, CancellationToken token)
{
await Save(run, token);
return await DB.Default.Find().MatchID(run.State.ID).Match(a => a.StopAsked).ExecuteAnyAsync(token);
}
// ---- posts
// what an outbox item is to this import: the object to import, or why not
(JsonObject Note, string Skipped) Importable(Run run, JsonObject item)
{
var type = Text(item["type"]);
if (type == "Announce")
return (default, "boosts skipped");
if (type != "Create" || item["object"] is not JsonObject note || !Postable.Contains(Text(note["type"]) ?? string.Empty))
return (default, "not a post");
if (Text(item["actor"]) != run.ArchiveActor || Id(note["attributedTo"]) is { } author && author != run.ArchiveActor)
return (default, "not the archive's own");
if (Text(note["id"]) == default)
return (default, "not a post");
return Visibility(run, note) == default ? (default, "direct or circle posts skipped") : (note, default);
}
PostVisibility? Visibility(Run run, JsonObject note)
{
var to = Strings(note["to"]);
var cc = Strings(note["cc"]);
if (to.Any(Public.Contains))
return PostVisibility.Public;
if (cc.Any(Public.Contains))
return PostVisibility.Unlisted;
if (to.Concat(cc).Contains(run.ArchiveFollowers) && Text(note["audience"]) == default)
return PostVisibility.FollowersOnly;
return default;
}
// what is already here of the archive's posts, for pins, filters and bookmarks when posts are not imported again
async Task KnownPosts(Run run, CancellationToken token)
{
await foreach (var item in run.Archive.Outbox(token))
{
if (Importable(run, item).Note is not { } note)
continue;
var original = Text(note["id"]);
var here = run.SameActor
? await DB.Default.Find().Match(p => p.ObjectURI == original && p.GroupUserId == run.Me.Id).ExecuteFirstAsync(token)
: await DB.Default.Find().Match(p => p.GroupUserId == run.Me.Id && p.ImportedFromURI == original).ExecuteFirstAsync(token);
if (here is { DeletedAt: null })
run.Posts[original] = (here.ID, here.ObjectURI);
}
}
async Task Posts(Run run, CancellationToken token)
{
// first the ids each post will have here, so a reply or a quote anywhere in the archive finds its copy
var planned = new Dictionary(StringComparer.Ordinal);
var total = 0L;
await foreach (var item in run.Archive.Outbox(token))
{
total++;
if (Importable(run, item).Note is not { } note)
continue;
var original = Text(note["id"]);
var published = Date(note["published"]) ?? DateTime.UtcNow;
if (Id(note["inReplyTo"]) is { } answers)
run.Parents[original] = answers;
if (run.SameActor)
{
var id = original.TrimEnd('/').Split('/')[^1];
if (!IsObjectId(id) || original != run.Me.PostUri(id))
id = PrivacyIds.At(Clamp(published));
var here = await DB.Default.Find().Match(p => p.ID == id || p.ObjectURI == original).ExecuteFirstAsync(token);
var tombstone = await DB.Default.Find().Match(d => d.ObjectURI == original).ExecuteAnyAsync(token);
if (here != default || tombstone)
{
if (here is { DeletedAt: null, GroupUserId: var owner } && owner == run.Me.Id)
run.Posts[original] = (here.ID, here.ObjectURI);
run.Count(here is { DeletedAt: not null } || tombstone ? "posts deleted here" : "posts already here");
continue;
}
planned[original] = (id, original, true);
}
else
{
var here = await DB.Default.Find().Match(p => p.GroupUserId == run.Me.Id && p.ImportedFromURI == original).ExecuteFirstAsync(token);
if (here != default)
{
if (here.DeletedAt == default)
run.Posts[original] = (here.ID, here.ObjectURI);
run.Count("posts already here");
continue;
}
var id = PrivacyIds.At(Clamp(published));
planned[original] = (id, run.Me.PostUri(id), true);
}
}
foreach (var (original, (id, uri, _)) in planned)
run.Posts[original] = (id, uri);
run.State.ImportTotal = total;
run.State.ImportDone = 0;
await foreach (var item in run.Archive.Outbox(token))
{
run.State.ImportDone++;
if (run.State.ImportDone % 50 == 0 && await Stopped(run, token))
return;
var (note, skipped) = Importable(run, item);
if (note == default)
{
run.Count(skipped);
continue;
}
var original = Text(note["id"]);
if (!planned.TryGetValue(original, out var target))
continue;
await Post(run, note, original, target.Id, target.Uri, Visibility(run, note).Value, token);
run.Count("posts");
}
}
async Task Post(Run run, JsonObject note, string original, string id, string uri, PostVisibility visibility, CancellationToken token)
{
var me = run.Me;
var summary = Clean(Text(note["summary"]));
var content = Text(note["content"]) ?? (note["contentMap"] as JsonObject)?.Select(c => Text(c.Value)).FirstOrDefault();
var source = note["source"] as JsonObject;
var post = new PostEntity
{
ID = id,
GroupUserId = me.Id,
AuthorAccountId = me.Id,
ActorURI = me.Uri,
Visibility = visibility,
Title = Clean(Text(note["name"])),
SpoilerText = summary,
HasContentWarning = summary != default || note["sensitive"]?.GetValueKind() == System.Text.Json.JsonValueKind.True,
ContentHtml = content == default ? default : ContentSanitizer.Html(content),
Text = Text(source?["mediaType"]) is "text/plain" or "text/markdown" ? Text(source["content"]) : default,
ContentFormat = ContentFormat.Html,
Language = (note["contentMap"] as JsonObject)?.Select(c => c.Key).FirstOrDefault(),
Tags = (note["tag"] as JsonArray ?? [])
.OfType().Where(t => Text(t["type"]) == "Hashtag").Select(t => TagsController.Normalise(Text(t["name"])))
.Where(t => t.Length > 0).Distinct().ToList(),
ObjectURI = uri,
Url = me.PostHtmlUrl(id),
ActivityURI = me.ActivityUri("create-" + id),
ImportedFromURI = run.SameActor ? default : original,
CreationDate = Clamp(Date(note["published"]) ?? DateTime.UtcNow),
LocalQuotePolicy = me.Settings.QuotePolicy ?? QuotePolicies.Public
};
if (Id(note["inReplyTo"]) is { } inReplyTo)
{
if (run.Posts.TryGetValue(inReplyTo, out var parent))
(post.AnsweringToPostId, post.InReplyToURI, post.InReplyToAccountId) = (parent.Id, parent.Uri, me.Id);
else
{
post.InReplyToURI = inReplyTo;
if (await DB.Default.Find().Match(p => p.ObjectURI == inReplyTo).ExecuteFirstAsync(token) is { } held)
(post.AnsweringToPostId, post.InReplyToAccountId) = (held.ID, held.AuthorAccountId ?? held.GroupUserId);
}
}
foreach (var key in new[] { "quote", "quoteUrl", "quoteUri", "_misskey_quote" })
if (Text(note[key]) is { } quoted && run.Posts.TryGetValue(quoted, out var copy))
{
(post.QuotedPostId, post.QuoteURI, post.QuoteState, post.QuoteByConsent) = (copy.Id, copy.Uri, QuoteState.Accepted, true);
break;
}
if (Text(note["type"]) == "Question")
post.Poll = Poll(note);
// the conversation (FEP-7888) of its thread's first post, when the thread starts in the archive
if (visibility is PostVisibility.Public or PostVisibility.Unlisted && Root(run, original) is { } root && run.Posts.TryGetValue(root, out var first))
post.ContextURI = first.Uri + "/context";
post.Media = await Media(run, note["attachment"] as JsonArray, id, token);
var rendered = ActivityPubRenderer.Note(post, me, default, post.InReplyToURI);
post.To = Strings(rendered["to"]);
post.Cc = Strings(rendered["cc"]);
await DB.Default.SaveAsync(post, token);
if (post.AnsweringToPostId != default)
await DB.Default.Update().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
}
// the first post of an archive post's thread, when the thread starts in the archive
static string Root(Run run, string original)
{
var seen = new HashSet(StringComparer.Ordinal);
while (run.Parents.TryGetValue(original, out var parent) && seen.Add(original))
{
if (!run.Posts.ContainsKey(parent))
return default;
original = parent;
}
return original;
}
static PostPoll Poll(JsonObject note)
{
var multiple = note["anyOf"] is JsonArray;
var options = (note["anyOf"] ?? note["oneOf"]) as JsonArray ?? [];
var ends = Date(note["endTime"]) ?? Date(note["closed"]) ?? DateTime.UtcNow;
var now = DateTime.UtcNow;
return new PostPoll
{
Options = options.OfType().Select(o => new PollOption { Title = Text(o["name"]) }).Where(o => o.Title != default).ToList(),
Multiple = multiple,
ExpiresAt = ends < now ? ends : now,
ClosedAt = ends < now ? ends : now,
VotersCount = 0
};
}
// the attachments the archive holds, uploaded as the persona's own (checked, processed, counted against its quota)
async Task> Media(Run run, JsonArray attachments, string postId, CancellationToken token)
{
var made = new List();
foreach (var attachment in (attachments ?? []).OfType().Take(MaxMedia))
{
var url = Text(attachment["url"]) ?? Text((attachment["url"] as JsonArray)?.OfType().FirstOrDefault()?["href"]);
var file = url?.TrimStart('/');
if (file == default || file.Contains("://") || !run.Archive.Has(file))
{
run.Count("media not in the archive");
continue;
}
var focus = attachment["focalPoint"] is JsonArray { Count: 2 } point ? $"{point[0]},{point[1]}" : default;
var outcome = await Upload(run, file, Text(attachment["mediaType"]), Clean(Text(attachment["name"])), focus, token);
if (outcome?.Ok != true)
{
run.Count("media refused");
continue;
}
var row = outcome.Attachment;
await DB.Default.Update().MatchID(row.ID).Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
made.Add(new PostMedia
{
AttachmentId = row.ID,
ContentType = row.ContentType,
Kind = row.Kind,
DurationSeconds = row.DurationSeconds,
URL = media.Url(row.FilePath),
PreviewURL = media.Url(row.PreviewPath ?? row.FilePath),
Description = row.Description,
Blurhash = row.Blurhash,
Width = row.Width,
Height = row.Height,
Focus = row.Focus
});
run.Count("media");
}
return made;
}
// a file of the archive given to MediaService as an upload would be
async Task Upload(Run run, string file, string contentType, string description, string focus, CancellationToken token)
{
var temporary = Path.Combine(Path.GetTempPath(), $"privapub-import-{Guid.NewGuid():N}{Path.GetExtension(file)}");
try
{
await using (var source = run.Archive.Read(file))
await using (var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write))
await source.CopyToAsync(target, token);
await using var stream = new FileStream(temporary, FileMode.Open, FileAccess.Read);
var form = new FormFile(stream, 0, stream.Length, "file", Path.GetFileName(file))
{
Headers = new HeaderDictionary(),
ContentType = contentType ?? (MediaService.ContentTypes.TryGetContentType(file, out var guessed) ? guessed : "application/octet-stream")
};
return await media.Upload(run.Me, form, description, focus, later: false, token);
}
finally
{
File.Delete(temporary);
}
}
// ---- PrivaPub's own parts
async Task Located(Run run, CancellationToken token)
{
foreach (var located in (run.Archive.Json("privapub/located.json") as JsonArray ?? []).OfType())
{
var original = Text(located["id"]);
if (original != default && await DB.Default.Find().Match(p => p.GroupUserId == run.Me.Id
&& (p.ObjectURI == original || p.ImportedFromURI == original)).ExecuteAnyAsync(token))
{
run.Count("located posts already here");
continue;
}
if (located["latitude"]?.GetValue() is not { } latitude || located["longitude"]?.GetValue() is not { } longitude)
continue;
var id = PrivacyIds.At(Clamp(Date(located["published"]) ?? DateTime.UtcNow));
var post = new PostEntity
{
ID = id,
GroupUserId = run.Me.Id,
AuthorAccountId = run.Me.Id,
ActorURI = run.Me.Uri,
Visibility = PostVisibility.LocalGeo,
IsLocalOnly = true,
Title = Clean(Text(located["title"])),
SpoilerText = Clean(Text(located["spoilerText"])),
HasContentWarning = Clean(Text(located["spoilerText"])) != default,
Text = Text(located["text"]),
ContentHtml = ActivityPubRenderer.Html(Text(located["text"]) ?? string.Empty),
ContentFormat = ContentFormat.Markdown,
Geo = new MongoDB.Entities.Coordinates2D(Math.Round(longitude, 2), Math.Round(latitude, 2)),
RangeKm = (float)Math.Clamp(located["rangeKm"]?.GetValue() ?? 5, 1, 50),
ObjectURI = run.Me.PostUri(id),
Url = run.Me.PostHtmlUrl(id),
ImportedFromURI = original,
CreationDate = Clamp(Date(located["published"]) ?? DateTime.UtcNow)
};
var files = (located["media"] as JsonArray ?? []).OfType()
.Select(m => new JsonObject { ["url"] = Text(m["url"]), ["name"] = Text(m["description"]) }).ToArray();
post.Media = await Media(run, new JsonArray(files), id, token);
await DB.Default.SaveAsync(post, token);
run.Count("located posts");
}
}
async Task Scheduled(Run run, CancellationToken token)
{
var made = new List();
foreach (var scheduled in (run.Archive.Json("privapub/scheduled.json") as JsonArray ?? []).OfType())
{
var at = Date(scheduled["scheduledAt"]);
if (at is not { } when || when < DateTime.UtcNow.AddMinutes(5) || await ScheduledStatuses.Refusal(run.Me.Id, when, default, token) != default)
{
run.Count("scheduled posts past or refused");
continue;
}
var status = new ScheduledStatus
{
AvatarId = run.Me.Id,
ScheduledAt = when,
Params = new ScheduledParams
{
Text = Text(scheduled["text"]),
SpoilerText = Text(scheduled["spoilerText"]),
Sensitive = scheduled["sensitive"]?.GetValueKind() == System.Text.Json.JsonValueKind.True,
Visibility = Text(scheduled["visibility"]) is { } visibility && visibility is "public" or "unlisted" or "private" or "direct" ? visibility : "public",
Language = Text(scheduled["language"])
}
};
status.ID = (string)status.GenerateNewID();
foreach (var file in (scheduled["media"] as JsonArray ?? []).OfType().Take(MaxMedia))
{
var path = Text(file["url"])?.TrimStart('/');
if (path == default || !run.Archive.Has(path))
continue;
var outcome = await Upload(run, path, default, Clean(Text(file["description"])), default, token);
if (outcome?.Ok != true)
continue;
await DB.Default.Update().MatchID(outcome.Attachment.ID).Modify(m => m.ScheduledStatusId, status.ID).ExecuteAsync(token);
status.Params.MediaIds.Add(outcome.Attachment.ID);
}
await DB.Default.SaveAsync(status, token);
made.Add(status);
run.Count("scheduled posts");
}
if (made.Count > 0)
await jobs.EnqueueMany(made.Select(s => ScheduledStatuses.JobFor(s, new Uri(localActors.BaseAddress).Host)), token);
}
async Task Tags(Run run, CancellationToken token)
{
var mine = (await DB.Default.Find().Match(t => t.AvatarId == run.Me.Id).Project(t => t.Name).ExecuteAsync(token)).ToHashSet();
foreach (var name in (run.Archive.Json("privapub/followed_tags.json") as JsonArray ?? []).Select(t => TagsController.Normalise(Text(t))))
{
if (name.Length == 0 || mine.Contains(name) || mine.Count >= 500)
continue;
await DB.Default.SaveAsync(new FollowedTag { AvatarId = run.Me.Id, Name = name }, token);
mine.Add(name);
run.Count("followed hashtags");
}
}
async Task Policy(Run run, CancellationToken token)
{
if (run.Archive.Json("privapub/notification_policy.json") is not JsonObject json)
return;
var policy = await NotificationPolicies.Of(run.Me.Id, token);
NotificationAction Action(string field, NotificationAction kept) => Enum.TryParse(Text(json[field]), out var action) ? action : kept;
policy.ForNotFollowing = Action("forNotFollowing", policy.ForNotFollowing);
policy.ForNotFollowers = Action("forNotFollowers", policy.ForNotFollowers);
policy.ForNewAccounts = Action("forNewAccounts", policy.ForNewAccounts);
policy.ForPrivateMentions = Action("forPrivateMentions", policy.ForPrivateMentions);
policy.ForLimitedAccounts = Action("forLimitedAccounts", policy.ForLimitedAccounts);
await NotificationPolicies.Save(policy, token);
run.Count("notification policy");
}
async Task Filters(Run run, CancellationToken token)
{
var titles = (await DB.Default.Find().Match(f => f.AvatarId == run.Me.Id).Project(f => f.Title).ExecuteAsync(token)).ToHashSet();
foreach (var filter in (run.Archive.Json("privapub/filters.json") as JsonArray ?? []).OfType())
{
var title = Clean(Text(filter["title"]));
if (title == default || titles.Contains(title))
continue;
var statuses = new List();
foreach (var uri in Strings(filter["statuses"]))
if (await Held(run, uri, token) is { } post)
statuses.Add(new FilterStatus { Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(), PostId = post.ID });
await DB.Default.SaveAsync(new PersonaFilter
{
AvatarId = run.Me.Id,
Title = title,
Context = Strings(filter["context"]).Where(c => c is "home" or "notifications" or "public" or "thread" or "account").ToList(),
ExpiresAt = Date(filter["expiresAt"]),
Action = Text(filter["action"]) is { } action && action is "warn" or "hide" or "blur" ? action : "warn",
Keywords = (filter["keywords"] as JsonArray ?? []).OfType().Where(k => Clean(Text(k["keyword"])) != default)
.Select(k => new FilterKeyword { Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(), Keyword = Text(k["keyword"]).Trim(),
WholeWord = k["wholeWord"]?.GetValueKind() == System.Text.Json.JsonValueKind.True }).ToList(),
Statuses = statuses
}, token);
titles.Add(title);
run.Count("filters");
}
}
// pinned again without announcing it (an Add would tell every follower's server)
async Task Pins(Run run, CancellationToken token)
{
var pinned = (await DB.Default.Find().Match(p => p.AvatarId == run.Me.Id).Project(p => p.PostId).ExecuteAsync(token)).ToHashSet();
foreach (var uri in Strings(run.Archive.Json("privapub/pins.json")))
{
if (pinned.Count >= 5)
break;
if (!run.Posts.TryGetValue(uri, out var copy) || pinned.Contains(copy.Id))
continue;
await DB.Default.SaveAsync(new Pin { AvatarId = run.Me.Id, PostId = copy.Id }, token);
pinned.Add(copy.Id);
run.Count("pins");
}
}
// ---- relationships
async Task DomainBlocks(Run run, CancellationToken token)
{
var relationships = run.Services.GetRequiredService();
var mine = (await DB.Default.Find().Match(d => d.AvatarId == run.Me.Id).Project(d => d.Domain).ExecuteAsync(token)).ToHashSet();
foreach (var line in run.Archive.Lines("blocked_domains.csv"))
{
var domain = Cells(line).FirstOrDefault()?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(domain) || domain == "#domain" || Uri.CheckHostName(domain) != UriHostNameType.Dns || mine.Contains(domain))
continue;
await relationships.BlockDomain(run.Me, domain, token);
mine.Add(domain);
run.Count("blocked servers");
}
}
// one CSV of accounts, each resolved (WebFinger or its address) and acted on; a header line is skipped
async Task Accounts(Run run, string file, string what, Func> act,
CancellationToken token)
{
var follows = run.Services.GetRequiredService();
var lines = run.Archive.Lines(file);
foreach (var line in lines)
{
if (await Stopped(run, token))
return;
var cells = Cells(line);
var address = cells.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(address) || address.Equals("Account address", StringComparison.OrdinalIgnoreCase))
continue;
var target = await follows.Resolve(address, token);
if (target.Local == default && target.Remote == default)
{
run.Count($"{what} not found");
continue;
}
if (target.Local?.Id == run.Me.Id)
continue;
run.Count(await act(run, target, cells, token) ? what : $"{what} skipped");
}
}
static async Task Block(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
{
var (uri, id) = target.Local != default ? (target.Local.Uri, target.Local.Id) : (target.Remote.ActorURI, target.Remote.ID);
if (await DB.Default.Find().Match(b => b.AvatarId == run.Me.Id && b.TargetActorURI == uri).ExecuteAnyAsync(token))
return false;
await run.Services.GetRequiredService().Block(run.Me, uri, id, token);
return true;
}
static async Task Mute(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
{
var (uri, id) = target.Local != default ? (target.Local.Uri, target.Local.Id) : (target.Remote.ActorURI, target.Remote.ID);
var hide = cells.Length < 2 || !cells[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
await run.Services.GetRequiredService().Mute(run.Me, uri, id, hide, default, token);
return true;
}
async Task Follow(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
{
var uri = target.Local?.Uri ?? target.Remote.ActorURI;
var host = new Uri(uri).Host;
if (domainBlocks.IsSuspended(host)
|| await DB.Default.Find().Match(d => d.AvatarId == run.Me.Id && d.Domain == host).ExecuteAnyAsync(token)
|| await DB.Default.Find().Match(f => f.AvatarId == run.Me.Id && f.TargetActorURI == uri).ExecuteAnyAsync(token))
return false;
var boosts = cells.Length < 2 || !cells[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
return await run.Services.GetRequiredService().FollowAs(run.Me, uri, boosts, token) != default;
}
// lists by title, with only the accounts the persona follows
async Task Lists(Run run, CancellationToken token)
{
var follows = run.Services.GetRequiredService();
var lists = (await DB.Default.Find().Match(l => l.AvatarId == run.Me.Id).ExecuteAsync(token)).ToDictionary(l => l.Title, StringComparer.Ordinal);
foreach (var line in run.Archive.Lines("lists.csv"))
{
var cells = Cells(line);
if (cells.Length < 2 || Clean(cells[0]) is not { } title)
continue;
if (!lists.TryGetValue(title, out var list))
{
if (lists.Count >= 50)
continue;
list = new PersonaList { AvatarId = run.Me.Id, Title = title, RepliesPolicy = "list" };
await DB.Default.SaveAsync(list, token);
lists[title] = list;
run.Count("lists");
}
var target = await follows.Resolve(cells[1].Trim(), token);
var uri = target.Local?.Uri ?? target.Remote?.ActorURI;
var following = uri == default ? default
: await DB.Default.Find().Match(f => f.AvatarId == run.Me.Id && f.TargetActorURI == uri && f.State == FollowState.Accepted).ExecuteFirstAsync(token);
if (following == default)
{
run.Count("list members not followed");
continue;
}
if (await DB.Default.Find().Match(m => m.ListId == list.ID && m.AccountId == following.TargetAccountId).ExecuteAnyAsync(token))
continue;
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = run.Me.Id, AccountId = following.TargetAccountId }, token);
run.Count("list members");
}
}
async Task Bookmarks(Run run, CancellationToken token)
{
var uris = Strings(run.Archive.Json("bookmarks.json")?["orderedItems"]);
if (uris.Count == 0)
uris = run.Archive.Lines("bookmarks.csv").Select(l => Cells(l).FirstOrDefault()?.Trim()).Where(u => u?.StartsWith("http") == true).ToList();
foreach (var uri in uris.Distinct())
{
if (await Stopped(run, token))
return;
var post = await Fetched(run, uri, token);
if (post == default)
{
run.Count("bookmarks not found");
continue;
}
if (await DB.Default.Find().Match(b => b.AvatarId == run.Me.Id && b.PostId == post.ID).ExecuteAnyAsync(token))
continue;
await DB.Default.SaveAsync(new Bookmark { AvatarId = run.Me.Id, PostId = post.ID }, token);
run.Count("bookmarks");
}
}
// asked for on purpose: each like tells the post's author
async Task Likes(Run run, CancellationToken token)
{
var statuses = run.Services.GetRequiredService();
foreach (var uri in Strings(run.Archive.Json("likes.json")?["orderedItems"]).Distinct())
{
if (await Stopped(run, token))
return;
var post = await Fetched(run, uri, token);
if (post == default)
{
run.Count("likes not found");
continue;
}
run.Count((await statuses.Favourite(run.Me, post.ID, true, token)).Ok ? "likes" : "likes not found");
}
}
// a post here by its address, or read from its server (signed) and kept
static async Task Fetched(Run run, string uri, CancellationToken token)
{
if (await Held(run, uri, token) is { } held)
return held;
return await run.Services.GetRequiredService().StoreContext(uri, 0, token);
}
static async Task Held(Run run, string uri, CancellationToken token)
{
if (uri == default)
return default;
if (run.Posts.TryGetValue(uri, out var copy))
return await DB.Default.Find().MatchID(copy.Id).ExecuteFirstAsync(token);
return await DB.Default.Find().Match(p => (p.ObjectURI == uri || p.Url == uri) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
}
// ---- the profile: name, words, fields, flags and pictures, saved as Settings saves them (and announced the same way)
async Task Profile(Run run, JsonObject actor, CancellationToken token)
{
var avatar = await DB.Default.Find().MatchID(run.Me.Id).ExecuteFirstAsync(token);
if (Clean(Text(actor["name"])) is { } name)
avatar.Name = name;
if (Text(actor["summary"]) is { } summary)
avatar.Biography = Plain(summary);
var fields = (actor["attachment"] as JsonArray ?? []).OfType().Where(f => Text(f["type"]) == "PropertyValue" && Clean(Text(f["name"])) != default)
.Take(4).ToDictionary(f => Text(f["name"]).Trim(), f => Plain(Text(f["value"]) ?? string.Empty));
if (fields.Count > 0)
avatar.Fields = fields;
if (actor["manuallyApprovesFollowers"]?.GetValueKind() is { } locked and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
avatar.Settings.IsLocked = locked == System.Text.Json.JsonValueKind.True;
if (actor["discoverable"]?.GetValueKind() is { } discoverable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
avatar.Settings.IsDiscoverable = discoverable == System.Text.Json.JsonValueKind.True;
if (actor["indexable"]?.GetValueKind() is { } indexable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
avatar.Settings.IsIndexable = indexable == System.Text.Json.JsonValueKind.True;
foreach (var (field, kind, width, height) in new[] { ("icon", "avatar", 400, 400), ("image", "header", 1500, 500) })
{
var file = (Text((actor[field] as JsonObject)?["url"]) ?? Text(actor[field]))?.TrimStart('/');
if (file == default || file.Contains("://") || !run.Archive.Has(file))
continue;
var outcome = await Picture(run, file, kind, width, height, token);
if (outcome?.Ok != true)
{
run.Count("pictures refused");
continue;
}
if (kind == "avatar")
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
else
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind && m.ID != outcome.Attachment.ID, "replaced", token);
}
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
await run.Services.GetRequiredService().PublishProfile(localActors.FromAvatar(avatar), token);
run.Count("profile");
}
async Task Picture(Run run, string file, string kind, int width, int height, CancellationToken token)
{
var temporary = Path.Combine(Path.GetTempPath(), $"privapub-import-{Guid.NewGuid():N}{Path.GetExtension(file)}");
try
{
await using (var source = run.Archive.Read(file))
await using (var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write))
await source.CopyToAsync(target, token);
await using var stream = new FileStream(temporary, FileMode.Open, FileAccess.Read);
var form = new FormFile(stream, 0, stream.Length, kind, Path.GetFileName(file))
{
Headers = new HeaderDictionary(),
ContentType = MediaService.ContentTypes.TryGetContentType(file, out var guessed) ? guessed : "application/octet-stream"
};
return await media.ProfileImage(run.Me.Id, kind, form, width, height, token);
}
finally
{
File.Delete(temporary);
}
}
// ---- helpers
static string Text(JsonNode node) => node is JsonValue value && value.TryGetValue(out var text) ? text : default;
static string Id(JsonNode node) => node switch
{
JsonValue => Text(node),
JsonObject json => Text(json["id"]),
JsonArray list => list.Select(Id).FirstOrDefault(i => i != default),
_ => default
};
static List Strings(JsonNode node) => node switch
{
JsonArray list => list.Select(Id).Where(i => i != default).ToList(),
JsonValue => Text(node) is { } one ? [one] : [],
JsonObject json when Text(json["type"]) is "OrderedCollection" or "Collection" => Strings(json["orderedItems"] ?? json["items"]),
_ => []
};
static DateTime? Date(JsonNode node) =>
DateTime.TryParse(Text(node), System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var date) ? date : default;
static DateTime Clamp(DateTime when) => when > DateTime.UtcNow ? DateTime.UtcNow : when;
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
static bool IsObjectId(string id) => id is { Length: 24 } && id.All(char.IsAsciiHexDigitLower);
// a CSV line's cells (quotes as Mastodon writes them)
static string[] Cells(string line)
{
var cells = new List();
var cell = new System.Text.StringBuilder();
var quoted = false;
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (quoted)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
{
cell.Append('"');
i++;
}
else if (c == '"')
quoted = false;
else
cell.Append(c);
}
else if (c == '"')
quoted = true;
else if (c == ',')
{
cells.Add(cell.ToString());
cell.Clear();
}
else
cell.Append(c);
}
cells.Add(cell.ToString());
return [.. cells];
}
// HTML to the plain words a persona writes in its profile
static string Plain(string html)
{
var text = Breaks().Replace(html, "\n");
text = Tags().Replace(text, string.Empty);
return WebUtility.HtmlDecode(text).Trim();
}
[GeneratedRegex(@"
|
\s*]*>", RegexOptions.IgnoreCase)]
private static partial Regex Breaks();
[GeneratedRegex(@"<[^>]+>")]
private static partial Regex Tags();
}
}