using Microsoft.Extensions.Options; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Models; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Security.Cryptography; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Federation.Actors { public class LocalActor { public string Id { get; init; } public LocalActorKind Kind { get; init; } public string UserName { get; init; } public string Name { get; init; } public string Summary { get; init; } public string PictureURL { get; init; } public string ThumbnailURL { get; init; } public string PrivateKeyPem { get; init; } public string PublicKeyPem { get; init; } public string SigningKey { get; init; }//a persona's Ed25519 seed (FEP-521a), for the proofs its activities carry (FEP-8b32) public bool Discoverable { get; init; } = true; public bool ManuallyApprovesFollowers { get; init; } public bool IsFederated { get; init; } = true; public bool IsCircle { get; init; } public bool PostingRestrictedToModerators { get; init; } public bool IsBot { get; init; } public bool Indexable { get; init; } public AvatarSettings Settings { get; init; } = new(); public DateTime Published { get; init; } public string BaseAddress { get; init; } public IReadOnlyDictionary Fields { get; init; } = new Dictionary(); public string Uri => $"{BaseAddress}/peasants/{UserName}"; public string KeyId => $"{Uri}#main-key"; public string AssertionKeyId => $"{Uri}#ed25519-key"; public string Inbox => $"{Uri}/mouth"; public string Outbox => $"{Uri}/anus"; public string Followers => $"{Uri}/groupies"; public string Following => $"{Uri}/stalking"; public string Featured => $"{Uri}/trophies"; public string FeaturedTags => $"{Uri}/tattoos"; public string Wall => $"{Uri}/graffiti"; public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated; // the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter or LocalActorKind.Reader; public string Flock => $"{Uri}/flock"; public string Wardens => $"{Uri}/wardens"; public string SharedInbox => $"{BaseAddress}/human-centipede"; public string Domain => new Uri(BaseAddress).Authority; public string Handle => $"{UserName}@{Domain}"; public string HtmlUrl => $"{BaseAddress}/@{UserName}"; public string PostUri(string postId) => $"{Uri}/scribbles/{postId}"; public string PostHtmlUrl(string postId) => $"{HtmlUrl}/{postId}"; public string ActivityUri(string activityId) => $"{Uri}/grunts/{activityId}"; public string ConversationUri(string conversationId) => $"{Uri}/whispers/{conversationId}"; } public interface ILocalActorService { string BaseAddress { get; } Task FindByUserName(string userName, CancellationToken token); Task Gone(string userName, CancellationToken token); Task FindById(LocalActorKind kind, string id, CancellationToken token); Task FindByUri(string actorUri, CancellationToken token); Task FindByAddress(string address, CancellationToken token); Task GetInstanceActor(CancellationToken token); Task GetReporterActor(CancellationToken token); Task GetReaderActor(CancellationToken token); Task IsUserNameTaken(string userName, CancellationToken token); Task TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token); LocalActor FromAvatar(Avatar avatar); LocalActor FromGroup(GroupEntity group); } // a persona or group that was deleted: its name stays reserved, and its documents answer 410 with this public sealed record GoneActor(string Uri, string FormerType, DateTime DeletedAt); public class LocalActorService : ILocalActorService { public const string InstanceUserName = "privapub"; // the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application // (owner decision 2026-10-06): one actor for the server, never one per persona public const string ReporterUserName = "privapub_reports"; // the anonymous Service that follows the communities of the feeds personas read, so no community learns which persona // reads it (owner decision 2026-10-07); a Service, since Lemmy takes a follow from no Application public const string ReaderUserName = "privapub_feeds"; public static bool IsServerActorName(string userName) => string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReaderUserName, StringComparison.OrdinalIgnoreCase); static readonly HashSet ReservedByInstance = new(StringComparer.Ordinal) { InstanceUserName, ReporterUserName, ReaderUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod", "abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined" }; readonly DbEntities _dbEntities; readonly IOptionsMonitor _appConfiguration; InstanceActor _instanceActor; ReporterActor _reporterActor; ReaderActor _readerActor; public LocalActorService(DbEntities dbEntities, IOptionsMonitor appConfiguration) { _dbEntities = dbEntities; _appConfiguration = appConfiguration; } public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/'); public async Task FindByUserName(string userName, CancellationToken token) { if (string.IsNullOrEmpty(userName)) return default; userName = userName.ToLowerInvariant(); if (userName == InstanceUserName) return await GetInstanceActor(token); if (userName == ReporterUserName) return await GetReporterActor(token); if (userName == ReaderUserName) return await GetReaderActor(token); var avatar = await _dbEntities.Avatars .Match(a => a.UserName == userName && !a.DeletionAt.HasValue) .ExecuteFirstAsync(token); if (avatar != default) return FromAvatar(avatar); var group = await _dbEntities.Groups .Match(g => g.UserName == userName && !g.DeletionAt.HasValue) .ExecuteFirstAsync(token); return group == default ? default : FromGroup(group); } public async Task Gone(string userName, CancellationToken token) { if (string.IsNullOrEmpty(userName)) return default; userName = userName.ToLowerInvariant(); var avatar = await _dbEntities.Avatars.Match(a => a.UserName == userName && a.DeletionAt.HasValue).ExecuteFirstAsync(token); if (avatar != default) return new GoneActor($"{BaseAddress}/peasants/{avatar.UserName}", "Person", avatar.DeletionAt.Value); var group = await _dbEntities.Groups.Match(g => g.UserName == userName && g.DeletionAt.HasValue).ExecuteFirstAsync(token); return group == default ? default : new GoneActor($"{BaseAddress}/peasants/{group.UserName}", "Group", group.DeletionAt.Value); } public async Task FindById(LocalActorKind kind, string id, CancellationToken token) { switch (kind) { case LocalActorKind.Person: var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token); return avatar == default ? default : FromAvatar(avatar); case LocalActorKind.Group: var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token); return group == default ? default : FromGroup(group); case LocalActorKind.Reporter: return await GetReporterActor(token); case LocalActorKind.Reader: return await GetReaderActor(token); default: return await GetInstanceActor(token); } } public Task FindByUri(string actorUri, CancellationToken token) { var prefix = $"{BaseAddress}/peasants/"; if (string.IsNullOrEmpty(actorUri) || !actorUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return Task.FromResult(default); var userName = actorUri[prefix.Length..].Split('/', '#', '?')[0]; return FindByUserName(userName, token); } // an actor id, or the profile page its document names as `url`: Mbin addresses its private messages to that page public Task FindByAddress(string address, CancellationToken token) { var profile = $"{BaseAddress}/@"; if (string.IsNullOrEmpty(address) || !address.StartsWith(profile, StringComparison.OrdinalIgnoreCase)) return FindByUri(address, token); var userName = address[profile.Length..]; return userName.Length == 0 || userName.IndexOfAny(['/', '#', '?']) >= 0 ? Task.FromResult(default) : FindByUserName(userName, token); } public async Task GetInstanceActor(CancellationToken token) { var instance = _instanceActor ??= await LoadInstanceActor(token); return new LocalActor { Id = instance.ID, Kind = LocalActorKind.Application, UserName = InstanceUserName, Name = "PrivaPub", Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.", PrivateKeyPem = instance.PrivateKey, PublicKeyPem = instance.PublicKey, Discoverable = false, Published = instance.CreationDate, BaseAddress = BaseAddress }; } public async Task GetReporterActor(CancellationToken token) { var reporter = _reporterActor ??= await LoadReporterActor(token); return new LocalActor { Id = reporter.ID, Kind = LocalActorKind.Reporter, UserName = ReporterUserName, Name = $"Reports from {new Uri(BaseAddress).Host}", Summary = "It carries this PrivaPub server's reports to the servers that take them only from a person or a service; " + "it never names who made them.", PrivateKeyPem = reporter.PrivateKey, PublicKeyPem = reporter.PublicKey, Discoverable = false, Published = reporter.CreationDate, BaseAddress = BaseAddress }; } public async Task GetReaderActor(CancellationToken token) { var reader = _readerActor ??= await LoadReaderActor(token); return new LocalActor { Id = reader.ID, Kind = LocalActorKind.Reader, UserName = ReaderUserName, Name = $"Feeds read on {new Uri(BaseAddress).Host}", Summary = "It follows the communities in the feeds this PrivaPub server's people read; it never names who reads them.", PrivateKeyPem = reader.PrivateKey, PublicKeyPem = reader.PublicKey, Discoverable = false, Published = reader.CreationDate, BaseAddress = BaseAddress }; } async Task LoadReaderActor(CancellationToken token) { var reader = await _dbEntities.ReaderActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token); if (reader != default) return reader; var (privateKey, publicKey) = Keys.NewKeyPair(); reader = new ReaderActor { PrivateKey = privateKey, PublicKey = publicKey }; await DB.Default.SaveAsync(reader, token); return reader; } async Task LoadReporterActor(CancellationToken token) { var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token); if (reporter != default) return reporter; var (privateKey, publicKey) = Keys.NewKeyPair(); reporter = new ReporterActor { PrivateKey = privateKey, PublicKey = publicKey }; await DB.Default.SaveAsync(reporter, token); return reporter; } async Task LoadInstanceActor(CancellationToken token) { var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token); if (instance != default) return instance; var (privateKey, publicKey) = Keys.NewKeyPair(); instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey }; await DB.Default.SaveAsync(instance, token); return instance; } public async Task IsUserNameTaken(string userName, CancellationToken token) { userName = userName?.ToLowerInvariant(); if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName)) return true; return await DB.Default.Find().Match(r => r.Name == userName).ExecuteAnyAsync(token); } public async Task TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) { userName = userName?.ToLowerInvariant(); if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName)) return false; try { await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token); return true; } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return false; } } public LocalActor FromAvatar(Avatar avatar) => new() { Id = avatar.ID, Kind = LocalActorKind.Person, UserName = avatar.UserName, Name = string.IsNullOrEmpty(avatar.Name) ? avatar.UserName : avatar.Name, Summary = avatar.Biography, PictureURL = avatar.PictureURL, ThumbnailURL = avatar.ThumbnailURL, PrivateKeyPem = avatar.PrivateKey, PublicKeyPem = avatar.PublicKey, SigningKey = avatar.SigningKey, Published = avatar.PublishedOn, Fields = avatar.Fields ?? new Dictionary(), ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true, Discoverable = avatar.Settings?.IsDiscoverable != false, IsBot = avatar.Settings?.IsBot == true, Indexable = avatar.Settings?.IsIndexable == true, Settings = avatar.Settings ?? new AvatarSettings(), BaseAddress = BaseAddress }; public LocalActor FromGroup(GroupEntity group) => new() { Id = group.ID, Kind = LocalActorKind.Group, UserName = group.UserName, Name = string.IsNullOrEmpty(group.Name) ? group.UserName : group.Name, Summary = group.Description, PictureURL = group.PictureURL, ThumbnailURL = group.ThumbnailURL, PrivateKeyPem = group.PrivateKey, PublicKeyPem = group.PublicKey, Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable, ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers, IsCircle = group.Kind == GroupKind.Circle, PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators, Published = group.PublishedOn, BaseAddress = BaseAddress }; } public static class Keys { public static (string PrivateKeyPem, string PublicKeyPem) NewKeyPair() { using var rsa = RSA.Create(2048); return (rsa.ExportRSAPrivateKeyPem(), rsa.ExportSubjectPublicKeyInfoPem()); } public static string ToSubjectPublicKeyInfoPem(string publicKeyPem) { if (string.IsNullOrEmpty(publicKeyPem) || publicKeyPem.Contains("BEGIN PUBLIC KEY")) return publicKeyPem; using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); return rsa.ExportSubjectPublicKeyInfoPem(); } } }