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 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 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 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 FindById(LocalActorKind kind, string id, CancellationToken token); Task FindByUri(string actorUri, CancellationToken token); Task GetInstanceActor(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); } public class LocalActorService : ILocalActorService { public const string InstanceUserName = "privapub"; static readonly HashSet ReservedByInstance = new(StringComparer.Ordinal) { InstanceUserName, "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; 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); 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 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); 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); } 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 }; } 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, 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(); } } }