PrivaPub dropped Move as an unknown type and showed no `moved` on accounts. Now a Move is believed as Mastodon believes it: the moving account sends it about itself, and the new account, read again from its own server, names it in alsoKnownAs (now kept on remote accounts). The old account then shows the new one as `moved` in the Mastodon API. The personas following it keep following it: following the new account on their behalf would tell another server about them, so that waits for the owner. Checked live against GoToSocial (scenarios/moves.sh: an alias, a move, 6 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
144 lines
6.4 KiB
C#
144 lines
6.4 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Post;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public sealed record ActorKey(string Id, string Pem);
|
|
|
|
public sealed class ActorDocument
|
|
{
|
|
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
|
|
|
|
public string Id { get; init; }
|
|
public string Type { get; init; }
|
|
public string PreferredUsername { get; init; }
|
|
public string Name { get; init; }
|
|
public string Summary { get; init; }
|
|
public string Url { get; init; }
|
|
public string Inbox { get; init; }
|
|
public string Outbox { get; init; }
|
|
public string Followers { get; init; }
|
|
public string Following { get; init; }
|
|
public string SharedInbox { get; init; }
|
|
public string Icon { get; init; }
|
|
public bool Discoverable { get; init; } = true;
|
|
public bool Indexable { get; init; }
|
|
public bool Locked { get; init; }
|
|
public bool Memorial { get; init; }
|
|
public string Header { get; init; }
|
|
public string IconDescription { get; init; }
|
|
public string HeaderDescription { get; init; }
|
|
public string MovedTo { get; init; }
|
|
public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one)
|
|
public DateTime? Published { get; init; }
|
|
public List<CustomEmoji> Emojis { get; init; } = new();
|
|
public Dictionary<string, string> Fields { get; init; } = new();
|
|
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
|
|
public List<string> Features { get; init; } = new();
|
|
|
|
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
|
|
|
|
// a property of URIs: one, an array of them, or objects carrying an id
|
|
static List<string> Uris(JsonElement value) => (value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : new List<JsonElement> { value })
|
|
.Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : RemoteActorService.Text(v, "id"))
|
|
.Where(v => Uri.TryCreate(v, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Take(20)
|
|
.ToList();
|
|
|
|
public static ActorDocument Parse(JsonElement root)
|
|
{
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
return default;
|
|
var id = RemoteActorService.Text(root, "id");
|
|
var type = RemoteActorService.Text(root, "type");
|
|
if (Origin.Of(id) == default || !ActorTypes.Contains(type))
|
|
return default;
|
|
|
|
var inbox = RemoteActorService.Text(root, "inbox");
|
|
return new ActorDocument
|
|
{
|
|
Id = id,
|
|
Type = type,
|
|
Features = ActorFeatures.Detect(root),
|
|
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
|
|
Name = RemoteActorService.Text(root, "name"),
|
|
Summary = RemoteActorService.Text(root, "summary"),
|
|
Url = RemoteActorService.Text(root, "url") ?? id,
|
|
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
|
Outbox = RemoteActorService.Text(root, "outbox"),
|
|
Followers = RemoteActorService.Text(root, "followers"),
|
|
Following = RemoteActorService.Text(root, "following"),
|
|
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
|
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
|
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
|
Keys = ParseKeys(root, id),
|
|
Indexable = Flag(root, "indexable"),
|
|
Locked = Flag(root, "manuallyApprovesFollowers"),
|
|
Memorial = Flag(root, "memorial"),
|
|
Header = root.TryGetProperty("image", out var image) ? ObjectShapes.Image(JsonNode.Parse(image.GetRawText())) : default,
|
|
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
|
|
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
|
|
MovedTo = RemoteActorService.Text(root, "movedTo"),
|
|
AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(),
|
|
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
|
|
? published.UtcDateTime
|
|
: default(DateTime?),
|
|
Emojis = root.TryGetProperty("tag", out var tags) ? ObjectShapes.Emojis(JsonNode.Parse(tags.GetRawText())) : new(),
|
|
Fields = root.TryGetProperty("attachment", out var attachment) ? ParseFields(JsonNode.Parse(attachment.GetRawText())) : new()
|
|
};
|
|
}
|
|
|
|
const int MaxFields = 16;
|
|
|
|
static bool Flag(JsonElement root, string property) =>
|
|
root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True;
|
|
|
|
static string Alt(JsonElement image) =>
|
|
image.ValueKind == JsonValueKind.Object ? ObjectShapes.Text(RemoteActorService.Text(image, "name") ?? RemoteActorService.Text(image, "summary"), 1500) : default;
|
|
|
|
static Dictionary<string, string> ParseFields(JsonNode attachment)
|
|
{
|
|
var fields = new Dictionary<string, string>();
|
|
foreach (var field in ObjectShapes.Objects(attachment).Where(a => ActivityJson.Value(a, "type") is "PropertyValue" or "Note"))
|
|
{
|
|
var name = ObjectShapes.Text(ActivityJson.Value(field, "name"), 255);
|
|
var value = ActivityJson.Value(field, "value") ?? ActivityJson.Value(field, "content");
|
|
if (name == default || value == default || fields.ContainsKey(name))
|
|
continue;
|
|
fields[name] = ContentSanitizer.Html(value.Length <= 2048 ? value : value[..2048]);
|
|
if (fields.Count == MaxFields)
|
|
break;
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
|
|
{
|
|
var keys = new List<ActorKey>();
|
|
if (!root.TryGetProperty("publicKey", out var publicKey))
|
|
return keys;
|
|
var candidates = publicKey.ValueKind switch
|
|
{
|
|
JsonValueKind.Object => new[] { publicKey },
|
|
JsonValueKind.Array => publicKey.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
|
|
_ => Array.Empty<JsonElement>()
|
|
};
|
|
foreach (var key in candidates)
|
|
{
|
|
var keyId = RemoteActorService.Text(key, "id");
|
|
var owner = RemoteActorService.Text(key, "owner");
|
|
var pem = RemoteActorService.Text(key, "publicKeyPem");
|
|
if (string.IsNullOrEmpty(pem) || owner != actorId || !Origin.Same(keyId, actorId))
|
|
continue;
|
|
keys.Add(new ActorKey(keyId, pem));
|
|
}
|
|
return keys;
|
|
}
|
|
}
|
|
}
|