Build / Build (push) Successful in 8m2s
- A post's flairs are read in both dialects: Lemmy 1.0's CommunityPostTag (id, slug, name, description, colour slot) and PieFed's lemmy:CommunityTag (display name, text and background colours, whether to blur images). Colours are kept only as a colour slot or a hex value. - A community's own list comes from its `tag` and PieFed's older `lemmy:tagsForPosts`, and fills in a post that names only a flair's id and slug, as Lemmy's posts do. Edits and refreshes keep them current. - Clients get them as `privapub.flairs`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
183 lines
8.6 KiB
C#
183 lines
8.6 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> 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),
|
|
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;
|
|
}
|
|
}
|
|
}
|