using Microsoft.Extensions.Options; using MongoDB.Entities; using PrivaPub.Models; using PrivaPub.Models.Federation; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Security.Cryptography; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Services.Federation { 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 DateTime Published { get; init; } public string BaseAddress { get; init; } 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}/followers"; public string Following => $"{Uri}/following"; public string SharedInbox => $"{BaseAddress}/human-centipede"; public string Domain => new Uri(BaseAddress).Authority; public string Handle => $"{UserName}@{Domain}"; public string PostUri(string postId) => $"{Uri}/posts/{postId}"; public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}"; } 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); LocalActor FromAvatar(Avatar avatar); LocalActor FromGroup(GroupEntity group); } public class LocalActorService : ILocalActorService { public const string InstanceUserName = "privapub"; readonly DbEntities _dbEntities; readonly IOptionsMonitor _appConfiguration; 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 = await _dbEntities.InstanceActors.ExecuteFirstAsync(token); if (instance == default) { var (privateKey, publicKey) = Keys.NewKeyPair(); instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey }; await DB.Default.SaveAsync(instance, 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 IsUserNameTaken(string userName, CancellationToken token) { userName = userName?.ToLowerInvariant(); if (string.IsNullOrEmpty(userName) || userName == InstanceUserName) return true; if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token)) return true; return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token); } 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.CreatedAt, 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.IsDiscoverable, ManuallyApprovesFollowers = group.ManuallyApprovesMembers, Published = group.CreationDate, 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(); } } }