using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Net; using GroupEntity = PrivaPub.Models.Group.Group; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Api.Mastodon.Mappers { public class MastodonMapper { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors) { _dbEntities = dbEntities; _localActors = localActors; } string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png"; string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png"; public async Task Local(LocalActor actor, bool withSource, CancellationToken token) { var followers = await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token); var following = actor.Kind == LocalActorKind.Person ? await DB.Default.CountAsync(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token) : 0; var latest = await _dbEntities.Posts .Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null) .Sort(p => p.ID, Order.Descending) .Limit(1) .ExecuteFirstAsync(token); var statuses = latest == default ? 0 : await DB.Default.CountAsync(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token); var account = new Account { Id = actor.Id, Username = actor.UserName, Acct = actor.UserName, DisplayName = actor.Name ?? actor.UserName, Locked = actor.ManuallyApprovesFollowers, Discoverable = actor.Discoverable, Group = actor.Kind == LocalActorKind.Group, CreatedAt = MastodonJson.Day(actor.Published), Note = ActivityPubRenderer.Html(actor.Summary), Url = actor.HtmlUrl, Uri = actor.Uri, Avatar = actor.PictureURL ?? MissingAvatar, AvatarStatic = actor.PictureURL ?? MissingAvatar, Header = actor.ThumbnailURL ?? MissingHeader, HeaderStatic = actor.ThumbnailURL ?? MissingHeader, FollowersCount = (int)followers, FollowingCount = (int)following, StatusesCount = (int)statuses, LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"), Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList() }; if (withSource) account.Source = new Source { Note = actor.Summary ?? string.Empty, Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), FollowRequestsCount = (int)await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && !f.IsAccepted, token) }; return account; } public Account Foreign(ForeignAvatar foreign) => new() { Id = foreign.ID, Username = foreign.UserName, Acct = $"{foreign.UserName}@{foreign.Domain}", DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty, Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application, Group = foreign.AvatarType == AvatarType.Group, Discoverable = foreign.IsDiscoverable, CreatedAt = MastodonJson.Day(foreign.CreatedAt), Note = foreign.Biography ?? string.Empty, Url = foreign.Url ?? foreign.ActorURI, Uri = foreign.ActorURI, Avatar = foreign.PictureURL ?? MissingAvatar, AvatarStatic = foreign.PictureURL ?? MissingAvatar, Header = foreign.ThumbnailURL ?? MissingHeader, HeaderStatic = foreign.ThumbnailURL ?? MissingHeader }; public async Task Account(string id, CancellationToken token) => (await Accounts(new[] { id }, token)).GetValueOrDefault(id); public async Task> Accounts(IEnumerable ids, CancellationToken token) { var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); var accounts = new Dictionary(); if (wanted.Count == 0) return accounts; foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token)) accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token); foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token)) accounts[foreign.ID] = Foreign(foreign); return accounts; } public async Task Status(PostEntity post, string viewerId, CancellationToken token) => (await Statuses(new[] { post }, viewerId, token)).FirstOrDefault(); public async Task> Statuses(IReadOnlyCollection posts, string viewerId, CancellationToken token) { var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); var originals = originalIds.Count == 0 ? new Dictionary() : (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); var all = posts.Concat(originals.Values).ToList(); var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList(); var mentionAccounts = mentionUris.Count == 0 ? new Dictionary() : (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI); var accounts = await Accounts(all.Select(AuthorOf), token); var ids = all.Select(p => p.ID).ToList(); var favourited = viewerId == default ? new HashSet() : (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet(); var reblogged = viewerId == default ? new HashSet() : (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) .Select(p => p.ReblogOfPostId).ToHashSet(); Status Map(PostEntity post) { if (!accounts.TryGetValue(AuthorOf(post), out var account)) return default; var status = new Status { Id = post.ID, Uri = post.ObjectURI, Url = post.Url ?? post.ObjectURI, Account = account, InReplyToId = post.AnsweringToPostId, InReplyToAccountId = post.InReplyToAccountId, Content = Content(post), CreatedAt = MastodonJson.Time(post.CreationDate), EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default, RepliesCount = post.RepliesCount, ReblogsCount = post.ReblogsCount, FavouritesCount = post.FavouritesCount, Favourited = favourited.Contains(post.ID), Reblogged = reblogged.Contains(post.ID), Sensitive = post.HasContentWarning, SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), Visibility = Visibility(post.Visibility), Language = post.Language, MediaAttachments = post.Media.Select(Media).ToList(), Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList() }; return status; } var mapped = new List(); foreach (var post in posts) { var status = Map(post); if (status == default) continue; if (post.ReblogOfPostId != default) { if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner) continue; status.Reblog = inner; status.Content = string.Empty; status.Reblogged = reblogged.Contains(original.ID); } mapped.Add(status); } return mapped; } public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId; public static string Content(PostEntity post) { var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text); return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy ? content ?? string.Empty : $"

{WebUtility.HtmlEncode(post.Title)}

{content}"; } public static string Visibility(PostVisibility visibility) => visibility switch { PostVisibility.Unlisted => "unlisted", PostVisibility.FollowersOnly or PostVisibility.Circle => "private", PostVisibility.Direct => "direct", _ => "public" }; StatusMention Mention(PostMention mention, IReadOnlyDictionary foreign) { if (mention.IsLocal) { var handle = mention.Handle?.TrimStart('@').Split('@')[0]; return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI }; } if (!foreign.TryGetValue(mention.ActorURI, out var account)) return default; return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI }; } static MediaAttachment Media(PostMedia media) => new() { Id = media.Id.ToString("N"), Type = media.ContentType switch { { } type when type.StartsWith("image/gif") => "gifv", { } type when type.StartsWith("image/") => "image", { } type when type.StartsWith("video/") => "video", { } type when type.StartsWith("audio/") => "audio", _ => "unknown" }, Url = media.URL ?? media.RemoteURL, PreviewUrl = media.URL ?? media.RemoteURL, RemoteUrl = media.RemoteURL, Description = media.Description, Blurhash = media.Blurhash, Meta = media.Width.HasValue && media.Height.HasValue ? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } } : default }; } }