Files
SocialPub/PrivaPub/Federation/Actors/ActorDocument.cs
T
thepraandClaude Opus 5.5 fb4949b511
Build / Build (push) Successful in 7m58s
P7: FEP-268d searchableBy decides what search finds
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:17:36 +02:00

185 lines
8.8 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 Featured { get; init; }
public string Wall { get; init; }
public string WebFinger { get; init; }//FEP-2c59: its handle, user@domain, where the domain is not its host's
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 string Moderators { get; init; }
public List<PostFlair> Flairs { get; init; } = new();
public List<string> SearchableBy { 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<Models.User.AssertionKey> AssertionKeys { get; init; } = new();
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"),
Featured = RemoteActorService.Text(root, "featured"),
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
WebFinger = RemoteActorService.Text(root, "webfinger")?.Replace("acct:", "", StringComparison.OrdinalIgnoreCase).TrimStart('@'),
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),
AssertionKeys = ParseAssertionKeys(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"),
Flairs = type != "Group" ? new() : ObjectShapes.Flairs(root.TryGetProperty("tag", out var labels) ? JsonNode.Parse(labels.GetRawText()) : default,
root.TryGetProperty("lemmy:tagsForPosts", out var older) ? JsonNode.Parse(older.GetRawText()) : default),
SearchableBy = root.TryGetProperty("searchableBy", out var searchable) ? ObjectShapes.SearchableBy(JsonNode.Parse(searchable.GetRawText())) : default,
Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default,
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;
}
const int MaxAssertionKeys = 5;
// its Ed25519 keys (FEP-521a) its document holds, as Mastodon reads them: Multikeys it controls, under its own id
static List<Models.User.AssertionKey> ParseAssertionKeys(JsonElement root, string actorId)
{
var keys = new List<Models.User.AssertionKey>();
if (!root.TryGetProperty("assertionMethod", out var methods))
return keys;
var candidates = methods.ValueKind switch
{
JsonValueKind.Object => new[] { methods },
JsonValueKind.Array => methods.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
_ => Array.Empty<JsonElement>()
};
foreach (var key in candidates.Take(MaxAssertionKeys))
{
var keyId = RemoteActorService.Text(key, "id");
var publicKey = Signing.IntegrityProofs.FromMultikey(RemoteActorService.Text(key, "publicKeyMultibase"));
if (RemoteActorService.Text(key, "type") != "Multikey" || RemoteActorService.Text(key, "controller") != actorId
|| keyId == default || !keyId.StartsWith(actorId + "#", StringComparison.Ordinal) || publicKey == default)
continue;
keys.Add(new Models.User.AssertionKey { Id = keyId, PublicKey = Convert.ToBase64String(publicKey) });
}
return keys;
}
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;
}
}
}