using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Domain.Media; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Globalization; 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; readonly IMediaProxy _proxy; public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default) { _dbEntities = dbEntities; _localActors = localActors; _proxy = proxy; } string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url); 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, Bot = actor.IsBot, Discoverable = actor.Discoverable, Indexable = actor.Indexable, Noindex = !actor.Indexable, HideCollections = actor.Settings.HideCollections, 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 { Privacy = actor.Settings.DefaultVisibility ?? "public", Sensitive = actor.Settings.DefaultSensitive, Language = actor.Settings.DefaultLanguage, 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 = Proxied(foreign.PictureURL) ?? MissingAvatar, AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar, Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader, HeaderStatic = Proxied(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 && g.Kind == GroupKind.Community).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 bookmarked = viewerId == default ? new HashSet() : (await DB.Default.Find().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet(); var pinned = (await DB.Default.Find().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet(); var hidden = viewerId == default ? new HashSet() : await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token); 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), Bookmarked = bookmarked.Contains(post.ID), Pinned = pinned.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(), Card = Card(post), Privapub = Extension(post), 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) { if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal) && hidden.Contains(hiddenOriginal.ActorURI)) continue; 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); if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt)) content = $"

{WebUtility.HtmlEncode(post.Excerpt)}

"; if (post.Event is { } happening && EventLine(happening) is { } line) content = $"

{WebUtility.HtmlEncode(line)}

{content}"; var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question")); return titled ? $"

{WebUtility.HtmlEncode(post.Title)}

{content}" : content ?? string.Empty; } public static string EventLine(EventDetails happening) { if (happening.Start is not { } start) return default; var zone = Zone(happening.FloatingTime ? default : happening.TimeZone); var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone); var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); if (happening.End is { } end && end > start) { var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone); when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})"; var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default) ?? (happening.OnlineURL != default ? "online" : default); return where == default ? when : $"{when} · {where}"; } static TimeZoneInfo Zone(string id) { if (string.IsNullOrWhiteSpace(id)) return default; try { return TimeZoneInfo.FindSystemTimeZoneById(id); } catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) { return default; } } PrivaPubStatus Extension(PostEntity post) => new() { ObjectType = post.ObjectType, Title = post.IsFederatedCopy ? post.Title : default, Excerpt = post.Excerpt, Cover = Proxied(post.CoverURL), Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType }, Link = post.Link == default ? default : new PrivaPubLink { Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher }, Video = post.Video == default ? default : new PrivaPubVideo { Duration = post.Video.DurationSeconds, Variants = post.Video.Variants.Select(Variant).ToList(), IsLive = post.Video.IsLive, State = post.Video.State, Views = post.Video.Views, CommentsPolicy = post.Video.CommentsPolicy, DownloadEnabled = post.Video.DownloadEnabled, Licence = post.Video.Licence, Category = post.Video.Category, Channel = post.Video.ChannelURI, Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList() }, Audio = post.Audio == default ? default : new PrivaPubAudio { Duration = post.Audio.DurationSeconds, Variants = post.Audio.Variants.Select(Variant).ToList(), Album = post.Audio.Album, Position = post.Audio.Position, Licence = post.Audio.Licence }, Event = post.Event == default ? default : new PrivaPubEvent { Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default, End = post.Event.End is { } end ? MastodonJson.Time(end) : default, TimeZone = post.Event.TimeZone, Floating = post.Event.FloatingTime, Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(), OnlineUrl = post.Event.OnlineURL, JoinMode = post.Event.JoinMode, Participants = post.Event.ParticipantCount, Capacity = post.Event.Capacity, Status = post.Event.Status, Category = post.Event.Category }, Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount } }; PrivaPubVariant Variant(MediaVariant variant) => new() { Url = Proxied(variant.Url), MediaType = variant.MediaType, Width = variant.Width, Height = variant.Height, Fps = variant.Fps, Size = variant.Size, Streams = variant.Streams }; PreviewCard Card(PostEntity post) { if (!post.IsFederatedCopy) return default; if (post.Link is { } link) return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL); if (post.ObjectType is "Article" or "Event" or "Video" or "Audio" && post.Url != default && post.Media.Count == 0) return Card(post.Url, post.Title, post.Excerpt, post.CoverURL); return default; } PreviewCard Card(string url, string title, string description, string image) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return default; return new PreviewCard { Url = url, Title = title ?? uri.Host, Description = description ?? string.Empty, ProviderName = uri.Host, ProviderUrl = uri.GetLeftPart(UriPartial.Authority), Image = Proxied(image) }; } 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 Dictionary Meta(PostMedia media) { var original = new Dictionary(); if (media.Width > 0 && media.Height > 0) { original["width"] = media.Width; original["height"] = media.Height; original["size"] = $"{media.Width}x{media.Height}"; original["aspect"] = (double)media.Width.Value / media.Height.Value; } if (media.DurationSeconds is { } seconds) original["duration"] = seconds; var meta = new Dictionary(); if (original.Count > 0) meta["original"] = original; if (media.DurationSeconds is { } length) meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture); if (media.Focus is { Length: 2 }) meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] }; return meta; } MediaAttachment Media(PostMedia media) => new() { Id = media.AttachmentId ?? 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 ?? Proxied(media.RemoteURL), PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL) ?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default), RemoteUrl = media.RemoteURL, Description = media.Description, Blurhash = media.Blurhash, Meta = Meta(media) }; } }