Files
SocialPub/PrivaPub/Domain/Portability/ImportArchiveJob.cs
T
thepraandClaude Opus 5.5 3d7d406834 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
2026-10-07 12:43:12 +02:00

929 lines
40 KiB
C#

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<ImportArchiveJob> logger) : IJobHandler
{
static readonly HashSet<string> Public = ["https://www.w3.org/ns/activitystreams#Public", "as:Public", "Public"];
static readonly HashSet<string> 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, bool sameActor, IServiceProvider services)
{
public PersonaArchive State { get; } = state;
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<string, (string Id, string Uri)> Posts { get; } = new(StringComparer.Ordinal);//archive object id → here
public DateTime LastSaved { get; set; }
public void Count(string what, int by = 1) => State.ImportCounts[what] = State.ImportCounts.GetValueOrDefault(what) + by;
}
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var state = await DB.Default.Find<PersonaArchive>().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, 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<bool> RootMayImport(string avatarId, CancellationToken token)
{
var link = await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
var root = link == default ? default : await DB.Default.Find<RootUser>().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<PersonaArchive>().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<PersonaArchive>().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<bool> Stopped(Run run, CancellationToken token)
{
await Save(run, token);
return await DB.Default.Find<PersonaArchive>().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;
var followers = run.ArchiveActor.TrimEnd('/') is var actor && run.Archive.Json("actor.json") is JsonObject document
? Text(document["followers"]) ?? actor + "/followers"
: default;
if (followers != default && to.Concat(cc).Contains(followers) && 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<PostEntity>().Match(p => p.ObjectURI == original && p.GroupUserId == run.Me.Id).ExecuteFirstAsync(token)
: await DB.Default.Find<PostEntity>().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<string, (string Id, string Uri, bool New)>(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 (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<PostEntity>().Match(p => p.ID == id || p.ObjectURI == original).ExecuteFirstAsync(token);
var tombstone = await DB.Default.Find<DeletedObject>().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<PostEntity>().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<JsonObject>().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<PostEntity>().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);
if (visibility is PostVisibility.Public or PostVisibility.Unlisted)
post.ContextURI = post.AnsweringToPostId != default && run.Posts.ContainsValue((post.AnsweringToPostId, post.InReplyToURI))
? post.InReplyToURI + "/context"
: post.ObjectURI + "/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<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
}
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<JsonObject>().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<List<PostMedia>> Media(Run run, JsonArray attachments, string postId, CancellationToken token)
{
var made = new List<PostMedia>();
foreach (var attachment in (attachments ?? []).OfType<JsonObject>().Take(MaxMedia))
{
var url = Text(attachment["url"]) ?? Text((attachment["url"] as JsonArray)?.OfType<JsonObject>().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<MediaAttachment>().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<MediaOutcome> 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<JsonObject>())
{
var original = Text(located["id"]);
if (original != default && await DB.Default.Find<PostEntity>().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<double>() is not { } latitude || located["longitude"]?.GetValue<double>() 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<double>() ?? 5, 1, 50),
ObjectURI = run.Me.PostUri(id),
Url = run.Me.PostHtmlUrl(id),
ImportedFromURI = original == run.Me.PostUri(original?.Split('/')[^1] ?? string.Empty) ? default : original,
CreationDate = Clamp(Date(located["published"]) ?? DateTime.UtcNow)
};
var files = (located["media"] as JsonArray ?? []).OfType<JsonObject>()
.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<ScheduledStatus>();
foreach (var scheduled in (run.Archive.Json("privapub/scheduled.json") as JsonArray ?? []).OfType<JsonObject>())
{
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<JsonObject>().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<MediaAttachment>().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<FollowedTag, string>().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<NotificationAction>(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<PersonaFilter, string>().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<JsonObject>())
{
var title = Clean(Text(filter["title"]));
if (title == default || titles.Contains(title))
continue;
var statuses = new List<FilterStatus>();
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<JsonObject>().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<Pin, string>().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<IRelationshipService>();
var mine = (await DB.Default.Find<AccountDomainBlock, string>().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<Run, (LocalActor Local, ForeignAvatar Remote), string[], CancellationToken, Task<bool>> act,
CancellationToken token)
{
var follows = run.Services.GetRequiredService<IFollowService>();
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<bool> 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<Block>().Match(b => b.AvatarId == run.Me.Id && b.TargetActorURI == uri).ExecuteAnyAsync(token))
return false;
await run.Services.GetRequiredService<IRelationshipService>().Block(run.Me, uri, id, token);
return true;
}
static async Task<bool> 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<IRelationshipService>().Mute(run.Me, uri, id, hide, default, token);
return true;
}
async Task<bool> 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<AccountDomainBlock>().Match(d => d.AvatarId == run.Me.Id && d.Domain == host).ExecuteAnyAsync(token)
|| await DB.Default.Find<Following>().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<IFollowService>().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<IFollowService>();
var lists = (await DB.Default.Find<PersonaList>().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<Following>().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<PersonaListMember>().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<Bookmark>().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<IStatusService>();
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<PostEntity> Fetched(Run run, string uri, CancellationToken token)
{
if (await Held(run, uri, token) is { } held)
return held;
return await run.Services.GetRequiredService<IRemotePosts>().StoreContext(uri, 0, token);
}
static async Task<PostEntity> 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<PostEntity>().MatchID(copy.Id).ExecuteFirstAsync(token);
return await DB.Default.Find<PostEntity>().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<Avatar>().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<JsonObject>().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<IOutboxPublisher>().PublishProfile(localActors.FromAvatar(avatar), token);
run.Count("profile");
}
async Task<MediaOutcome> 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<string>(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<string> 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<string>();
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(@"<br\s*/?>|</p>\s*<p[^>]*>", RegexOptions.IgnoreCase)]
private static partial Regex Breaks();
[GeneratedRegex(@"<[^>]+>")]
private static partial Regex Tags();
}
}