diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs index a43e93dc..977f3d94 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs @@ -22,25 +22,28 @@ public class NoteRenderer( var renote = note.Renote != null && recurse > 0 ? await RenderAsync(note.Renote, accounts, mentions, --recurse) : null; - var text = note.Text; //TODO: append quote uri - var content = text != null ? await mfmConverter.ToHtmlAsync(text, note.MentionedRemoteUsers) : null; + var text = note.Text; //TODO: append quote uri if (mentions == null) { mentions = await db.Users.Where(p => note.Mentions.Contains(p.Id)) - .Select(u => new Mention { - Id = u.Id, - Username = u.Username, - Acct = u.Acct, - Url = (u.UserProfile != null - ? u.UserProfile.Url ?? u.Uri - : u.Uri) ?? $"https://{config.Value.WebDomain}/@{u.Username}" - }) + .Select(u => new Mention(u, config.Value.WebDomain)) .ToListAsync(); } else { mentions = [..mentions.Where(p => note.Mentions.Contains(p.Id))]; } + var mentionedUsers = mentions.Select(p => new Note.MentionedUser { + Host = p.Host ?? config.Value.AccountDomain, + Uri = p.Uri, + Username = p.Username, + Url = p.Url + }).ToList(); + + var content = text != null + ? await mfmConverter.ToHtmlAsync(text, mentionedUsers, note.UserHost) + : null; + var account = accounts?.FirstOrDefault(p => p.Id == note.UserId) ?? await userRenderer.RenderAsync(note.User); var res = new Status { @@ -77,14 +80,7 @@ public class NoteRenderer( private async Task> GetMentions(IEnumerable notes) { var ids = notes.SelectMany(n => n.Mentions).Distinct(); return await db.Users.Where(p => ids.Contains(p.Id)) - .Select(u => new Mention { - Id = u.Id, - Username = u.Username, - Acct = u.Acct, - Url = u.UserProfile != null - ? u.UserProfile.Url ?? u.Uri ?? $"https://{config.Value.WebDomain}/@{u.Username}" - : u.Uri ?? $"https://{config.Value.WebDomain}/@{u.Username}" - }) + .Select(u => new Mention(u, config.Value.WebDomain)) .ToListAsync(); } diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs index 8b893296..2dcecf94 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs @@ -27,7 +27,7 @@ public class UserRenderer(IOptions config, MfmConverter FollowersCount = user.FollowersCount, FollowingCount = user.FollowingCount, StatusesCount = user.NotesCount, - Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", []), + Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", [], user.Host), Url = profile?.Url ?? user.Uri ?? $"https://{user.Host ?? config.Value.WebDomain}/@{user.Username}", AvatarStaticUrl = user.AvatarUrl ?? $"https://{config.Value.WebDomain}/identicon/{user.Id}", //TODO HeaderUrl = user.BannerUrl ?? _transparent, diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Mention.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Mention.cs index 8cfaa6a1..a8fc301d 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Mention.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Mention.cs @@ -1,10 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using Iceshrimp.Backend.Core.Database.Tables; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; -public class Mention { +public class Mention() { [J("id")] public required string Id { get; set; } [J("username")] public required string Username { get; set; } [J("acct")] public required string Acct { get; set; } [J("url")] public required string Url { get; set; } + + // internal properties that won't be serialized + [JI] public required string Uri; + [JI] public required string? Host; + + [SetsRequiredMembers] + public Mention(User u, string webDomain) : this() { + Id = u.Id; + Username = u.Username; + Host = u.Host; + Acct = u.Acct; + Uri = u.Uri ?? $"https://{webDomain}/users/{u.Id}"; + Url = u.UserProfile != null + ? u.UserProfile.Url ?? u.Uri ?? $"https://{webDomain}/@{u.Username}" + : u.Uri ?? $"https://{webDomain}/@{u.Username}"; + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs index 2a06929b..66b9a86d 100644 --- a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs @@ -5,6 +5,12 @@ using EntityFrameworkCore.Projectables; namespace Iceshrimp.Backend.Core.Extensions; public static class StringExtensions { + public static bool EqualsInvariant(this string s1, string s2) => + string.Equals(s1, s2, StringComparison.InvariantCulture); + + public static bool EqualsIgnoreCase(this string s1, string s2) => + string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase); + public static string Truncate(this string target, int maxLength) { return target[..Math.Min(target.Length, maxLength)]; } diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs index ac30187b..426ea553 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs @@ -1,12 +1,9 @@ using Iceshrimp.Backend.Core.Configuration; -using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.Backend.Core.Helpers.LibMfm.Serialization; using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Core.Federation.ActivityPub; @@ -17,68 +14,64 @@ using SplitDomainMapping = IReadOnlyDictionary<(string usernameLower, string web /// Resolves mentions into their canonical form. This is required for handling split domain mentions correctly, as it cannot be guaranteed that remote instances handle split domain users correctly. /// public class MentionsResolver( - DatabaseContext db, - IOptions config, - IDistributedCache cache + IOptions config ) { - public async Task ResolveMentions( + public string ResolveMentions( string mfm, string? host, List mentionCache, SplitDomainMapping splitDomainMapping ) { var nodes = MfmParser.Parse(mfm); - nodes = await ResolveMentions(nodes, host, mentionCache, splitDomainMapping); + nodes = ResolveMentions(nodes, host, mentionCache, splitDomainMapping); return MfmSerializer.Serialize(nodes); } - public async Task> ResolveMentions( + public IEnumerable ResolveMentions( IEnumerable nodes, string? host, List mentionCache, SplitDomainMapping splitDomainMapping ) { var nodesList = nodes.ToList(); - foreach (var mention in nodesList.SelectMany(p => p.Children.Append(p)).OfType()) - await ResolveMention(mention, host, mentionCache, splitDomainMapping); + + // We need to call .ToList() on this so we can modify the collection in the loop + foreach (var mention in nodesList.SelectMany(p => p.Children.Append(p)).OfType().ToList()) + nodesList[nodesList.IndexOf(mention)] = ResolveMention(mention, host, mentionCache, splitDomainMapping); return nodesList; } - private async Task ResolveMention( + private MfmInlineNode ResolveMention( MfmMentionNode node, string? host, IEnumerable mentionCache, SplitDomainMapping splitDomainMapping ) { - var finalHost = node.Host ?? host; + // Fall back to object host, as localpart-only mentions are relative to the instance the note originated from + node.Host ??= host ?? config.Value.AccountDomain; - if (finalHost == config.Value.AccountDomain || finalHost == config.Value.WebDomain) - finalHost = null; - if (finalHost != null && - splitDomainMapping.TryGetValue((node.Username.ToLowerInvariant(), finalHost), out var value)) - finalHost = value; + if (node.Host == config.Value.WebDomain) + node.Host = config.Value.AccountDomain; + + if (node.Host != config.Value.AccountDomain && + splitDomainMapping.TryGetValue((node.Username.ToLowerInvariant(), node.Host), out var value)) + node.Host = value; var resolvedUser = - mentionCache.FirstOrDefault(p => string.Equals(p.Username, node.Username, - StringComparison.InvariantCultureIgnoreCase) && - p.Host == finalHost); + mentionCache.FirstOrDefault(p => p.Username.EqualsIgnoreCase(node.Username) && p.Host == node.Host); if (resolvedUser != null) { node.Username = resolvedUser.Username; node.Host = resolvedUser.Host; node.Acct = $"@{resolvedUser.Username}@{resolvedUser.Host}"; - } - else { - async Task FetchLocalUserCapitalization() { - var username = await db.Users.Where(p => p.UsernameLower == node.Username.ToLowerInvariant()) - .Select(p => p.Username) - .FirstOrDefaultAsync(); - return username ?? node.Username; - } - node.Username = await cache.FetchAsync($"localUserNameCapitalization:{node.Username.ToLowerInvariant()}", - TimeSpan.FromHours(24), FetchLocalUserCapitalization); - - node.Host = config.Value.AccountDomain; - node.Acct = $"@{node.Username}@{config.Value.AccountDomain}"; + return node; } + + return new MfmPlainNode { + Children = [ + new MfmTextNode { + Text = node.Acct + } + ] + }; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs index 342358fc..ad50c6ff 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs @@ -51,22 +51,24 @@ public class NoteRenderer(IOptions config, MfmConverter return new ASNote { Id = id, - Content = note.Text != null ? await mfmConverter.ToHtmlAsync(note.Text, []) : null, AttributedTo = [new ASObjectBase(userId)], Type = $"{Constants.ActivityStreamsNs}#Note", MkContent = note.Text, PublishedAt = note.CreatedAt, Sensitive = note.Cw != null, InReplyTo = replyId, + Cc = cc, + To = to, + Tags = tags, + Content = note.Text != null + ? await mfmConverter.ToHtmlAsync(note.Text, mentions, note.UserHost) + : null, Source = note.Text != null ? new ASNoteSource { Content = note.Text, MediaType = "text/x.misskeymarkdown" } - : null, - Cc = cc, - To = to, - Tags = tags + : null }; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs index 2fae66ae..9bcfada4 100644 --- a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs +++ b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs @@ -6,6 +6,7 @@ using AngleSharp.Dom; using AngleSharp.Html.Parser; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; using Microsoft.Extensions.Options; @@ -31,44 +32,44 @@ public class MfmConverter(IOptions config) { return sb.ToString().Trim(); } - public async Task ToHtmlAsync(IEnumerable nodes, List mentions) { + public async Task ToHtmlAsync(IEnumerable nodes, List mentions, string? host) { var context = BrowsingContext.New(); var document = await context.OpenNewAsync(); var element = document.CreateElement("p"); - foreach (var node in nodes) element.AppendNodes(FromMfmNode(document, node, mentions)); + foreach (var node in nodes) element.AppendNodes(FromMfmNode(document, node, mentions, host)); await using var sw = new StringWriter(); await element.ToHtmlAsync(sw); return sw.ToString(); } - public async Task ToHtmlAsync(string mfm, List mentions) { + public async Task ToHtmlAsync(string mfm, List mentions, string? host) { var nodes = MfmParser.Parse(mfm); - return await ToHtmlAsync(nodes, mentions); + return await ToHtmlAsync(nodes, mentions, host); } - private INode FromMfmNode(IDocument document, MfmNode node, List mentions) { + private INode FromMfmNode(IDocument document, MfmNode node, List mentions, string? host) { switch (node) { case MfmBoldNode: { var el = document.CreateElement("b"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmSmallNode: { var el = document.CreateElement("small"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmStrikeNode: { var el = document.CreateElement("del"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmItalicNode: case MfmFnNode: { var el = document.CreateElement("i"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmCodeBlockNode codeBlockNode: { @@ -80,7 +81,7 @@ public class MfmConverter(IOptions config) { } case MfmCenterNode: { var el = document.CreateElement("div"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmEmojiCodeNode emojiCodeNode: { @@ -114,25 +115,20 @@ public class MfmConverter(IOptions config) { case MfmLinkNode linkNode: { var el = document.CreateElement("a"); el.SetAttribute("href", linkNode.Url); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmMentionNode mentionNode: { var el = document.CreateElement("span"); - if (mentionNode.Host == config.Value.AccountDomain || mentionNode.Host == config.Value.WebDomain) - mentionNode.Host = null; + // Fall back to object host, as localpart-only mentions are relative to the instance the note originated from + mentionNode.Host ??= host ?? config.Value.AccountDomain; - var mention = mentionNode.Host == null - ? new Note.MentionedUser { - Host = config.Value.AccountDomain, - Uri = $"https://{config.Value.WebDomain}/@{mentionNode.Username}", - Username = mentionNode.Username - } - : mentions.FirstOrDefault(p => string.Equals(p.Username, mentionNode.Username, - StringComparison.InvariantCultureIgnoreCase) && - string.Equals(p.Host, mentionNode.Host, - StringComparison.InvariantCultureIgnoreCase)); + if (mentionNode.Host == config.Value.WebDomain) + mentionNode.Host = config.Value.AccountDomain; + + var mention = mentions.FirstOrDefault(p => p.Username.EqualsIgnoreCase(mentionNode.Username) && + p.Host.EqualsIgnoreCase(mentionNode.Host)); if (mention == null) { el.TextContent = mentionNode.Acct; } @@ -152,7 +148,7 @@ public class MfmConverter(IOptions config) { } case MfmQuoteNode: { var el = document.CreateElement("blockquote"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } case MfmTextNode textNode: { @@ -188,7 +184,7 @@ public class MfmConverter(IOptions config) { } case MfmPlainNode: { var el = document.CreateElement("span"); - AppendChildren(el, document, node, mentions); + AppendChildren(el, document, node, mentions, host); return el; } default: { @@ -198,8 +194,8 @@ public class MfmConverter(IOptions config) { } private void AppendChildren(INode element, IDocument document, MfmNode parent, - List mentions + List mentions, string? host ) { - foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions)); + foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions, host)); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Helpers/LibMfm/Serialization/MfmSerializer.cs b/Iceshrimp.Backend/Core/Helpers/LibMfm/Serialization/MfmSerializer.cs index fd891a20..61c19163 100644 --- a/Iceshrimp.Backend/Core/Helpers/LibMfm/Serialization/MfmSerializer.cs +++ b/Iceshrimp.Backend/Core/Helpers/LibMfm/Serialization/MfmSerializer.cs @@ -80,7 +80,10 @@ public static class MfmSerializer { break; } case MfmPlainNode: { - result.Append(node.Children.OfType().Select(p => p.Text)); + result.Append(""); + foreach (var s in node.Children.OfType().Select(p => p.Text)) + result.Append(s); + result.Append(""); break; } case MfmSmallNode: { diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 62952d81..9fe8869b 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -49,7 +49,7 @@ public class NoteService( var (mentionedUserIds, mentions, remoteMentions, splitDomainMapping) = await ResolveNoteMentionsAsync(text); if (text != null) - text = await mentionsResolver.ResolveMentions(text, null, mentions, splitDomainMapping); + text = mentionsResolver.ResolveMentions(text, null, mentions, splitDomainMapping); var actor = await userRenderer.RenderAsync(user); @@ -184,8 +184,7 @@ public class NoteService( dbNote.VisibleUserIds = visibleUserIds.Distinct().ToList(); } - dbNote.Text = await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions, - splitDomainMapping); + dbNote.Text = mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, mentions, splitDomainMapping); } user.NotesCount++; diff --git a/Iceshrimp.Tests/Parsing/MfmTests.cs b/Iceshrimp.Tests/Parsing/MfmTests.cs index 1386397a..a0550bdc 100644 --- a/Iceshrimp.Tests/Parsing/MfmTests.cs +++ b/Iceshrimp.Tests/Parsing/MfmTests.cs @@ -29,7 +29,7 @@ public class MfmTests { var converter = provider.GetRequiredService(); var pre = DateTime.Now; - await converter.ToHtmlAsync(Mfm, []); + await converter.ToHtmlAsync(Mfm, [], null); var post = DateTime.Now; var ms = (post - pre).TotalMilliseconds; Console.WriteLine($"Took {ms} ms");