using MongoDB.Bson; using MongoDB.Entities; using PrivaPub.Models.Federation; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace PrivaPub.Federation.Actors { // FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers carries a digest of its followers on // the receiving server, and that server reads which of its own accounts they are at the persona's roll-call, signed. A // server whose view differs (a Follow or an Undo lost on the way) mends it from there. No server learns of followers // anywhere else. public static class FollowersSynchronization { public const string Header = "Collection-Synchronization"; public static bool Synchronizes(LocalActor actor) => actor is { Kind: LocalActorKind.Person, IsCircle: false }; public static string RollCall(LocalActor actor) => actor.Followers + "/roll-call"; // a server, as Mastodon cuts a URL to tell where its accounts live: scheme and authority public static string Origin(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && parsed.Scheme is "https" or "http" ? $"{parsed.Scheme}://{parsed.Authority}" : default; // the actor's accepted followers whose ids live under origin public static async Task> On(LocalActor actor, string origin, CancellationToken token) { if (origin == default) return []; var under = new BsonRegularExpression("^" + Regex.Escape(origin + "/")); return await DB.Default.Find() .Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted) .Match(f => f.Regex(x => x.ActorURI, under)) .Project(f => f.ActorURI) .ExecuteAsync(token); } // the XOR of each id's SHA-256, in lower-case hex: the same set gives the same digest in any order public static string Digest(IEnumerable ids) { var digest = new byte[SHA256.HashSizeInBytes]; foreach (var id in ids) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(id)); for (var i = 0; i < digest.Length; i++) digest[i] ^= hash[i]; } return Convert.ToHexStringLower(digest); } public static string HeaderValue(LocalActor actor, string digest) => $"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\""; // whether the activity, or the object it carries, is addressed to the actor's followers public static bool ForFollowers(JsonObject activity, LocalActor actor) => Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers); static bool Addressed(JsonObject node, string followers) => new[] { "to", "cc", "bto", "bcc" }.Any(field => node[field] switch { JsonArray many => many.Any(a => a is JsonValue value && value.TryGetValue(out var text) && text == followers), JsonValue one => one.TryGetValue(out var text) && text == followers, _ => false }); } }