Build / Build (push) Successful in 8m37s
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
from an account the community's moderators collection lists. Remote communities keep that collection's address
(attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
comes from the server holding what was reported, or the community it was reported to; our own moderators'
resolution is never replaced. The moderators' report list shows both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
180 lines
8.3 KiB
C#
180 lines
8.3 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<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"),
|
|
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;
|
|
}
|
|
}
|
|
}
|