From 16d0199506c2cabb524a9c66506eddf70e5007ff Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Mon, 12 Feb 2024 04:24:09 +0100 Subject: [PATCH] [backend/federation] Handle outgoing mentions correctly (ISH-46) --- .../Controllers/ActivityPubController.cs | 7 +-- .../ActivityPub/MentionsResolver.cs | 3 +- .../Federation/ActivityPub/NoteRenderer.cs | 36 ++++++++++++--- .../Core/Services/NoteService.cs | 45 +++++++++++++++---- .../Core/Services/UserService.cs | 5 ++- 5 files changed, 76 insertions(+), 20 deletions(-) diff --git a/Iceshrimp.Backend/Controllers/ActivityPubController.cs b/Iceshrimp.Backend/Controllers/ActivityPubController.cs index 24fe41f4..1dbc39bd 100644 --- a/Iceshrimp.Backend/Controllers/ActivityPubController.cs +++ b/Iceshrimp.Backend/Controllers/ActivityPubController.cs @@ -3,6 +3,7 @@ using System.Text; using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Schemas; using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; @@ -27,9 +28,9 @@ public class ActivityPubController : Controller { [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task GetNote(string id, [FromServices] DatabaseContext db, [FromServices] NoteRenderer noteRenderer) { - var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id); + var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); if (note == null) return NotFound(); - var rendered = noteRenderer.RenderAsync(note); + var rendered = await noteRenderer.RenderAsync(note); var compacted = LdHelpers.Compact(rendered); return Ok(compacted); } @@ -43,7 +44,7 @@ public class ActivityPubController : Controller { public async Task GetUser(string id, [FromServices] DatabaseContext db, [FromServices] UserRenderer userRenderer) { - var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id); + var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); if (user == null) return NotFound(); var rendered = await userRenderer.RenderAsync(user); var compacted = LdHelpers.Compact(rendered); diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs index 765c7277..ac30187b 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/MentionsResolver.cs @@ -77,7 +77,8 @@ public class MentionsResolver( node.Username = await cache.FetchAsync($"localUserNameCapitalization:{node.Username.ToLowerInvariant()}", TimeSpan.FromHours(24), FetchLocalUserCapitalization); - node.Acct = $"@{node.Username}"; + node.Host = config.Value.AccountDomain; + node.Acct = $"@{node.Username}@{config.Value.AccountDomain}"; } } } \ 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 a695b6e8..17b12325 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs @@ -1,23 +1,37 @@ 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.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Core.Federation.ActivityPub; -public class NoteRenderer(IOptions config, MfmConverter mfmConverter) { - public async Task RenderAsync(Note note) { +public class NoteRenderer(IOptions config, MfmConverter mfmConverter, DatabaseContext db) { + public async Task RenderAsync(Note note, List? mentions = null) { var id = $"https://{config.Value.WebDomain}/notes/{note.Id}"; var userId = $"https://{config.Value.WebDomain}/users/{note.User.Id}"; var replyId = note.ReplyId != null ? new ASObjectBase($"https://{config.Value.WebDomain}/notes/{note.ReplyId}") : null; - List to = note.Visibility switch { + mentions ??= await db.Users + .Where(p => note.Mentions.Contains(p.Id)) + .IncludeCommonProperties() + .Select(p => new Note.MentionedUser { + Host = p.Host ?? config.Value.AccountDomain, + Username = p.Username, + Url = p.UserProfile != null ? p.UserProfile.Url : null, + Uri = p.Uri ?? $"https://{config.Value.WebDomain}/users/{p.Id}" + }) + .ToListAsync(); + + var to = note.Visibility switch { Note.NoteVisibility.Public => [new ASLink($"{Constants.ActivityStreamsNs}#Public")], Note.NoteVisibility.Followers => [new ASLink($"{userId}/followers")], - Note.NoteVisibility.Specified => [], // FIXME + Note.NoteVisibility.Specified => mentions.Select(p => new ASObjectBase(p.Uri)).ToList(), _ => [] }; @@ -26,6 +40,15 @@ public class NoteRenderer(IOptions config, MfmConverter _ => [] }; + var tags = mentions + .Select(mention => new ASMention { + Type = $"{Constants.ActivityStreamsNs}#Mention", + Name = $"@{mention.Username}@{mention.Host}", + Href = new ASObjectBase(mention.Uri) + }) + .Cast() + .ToList(); + return new ASNote { Id = id, Content = note.Text != null ? await mfmConverter.ToHtmlAsync(note.Text, []) : null, @@ -41,8 +64,9 @@ public class NoteRenderer(IOptions config, MfmConverter MediaType = "text/x.misskeymarkdown" } : null, - Cc = cc, - To = to + Cc = cc, + To = to, + Tags = tags }; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 5dc4d12a..1caab980 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -7,6 +7,8 @@ using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; +using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; +using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; using Iceshrimp.Backend.Core.Middleware; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -44,6 +46,11 @@ public class NoteService( if (text is { Length: > 100000 }) throw GracefulException.BadRequest("Text cannot be longer than 100.000 characters"); + var (mentionedUserIds, mentions, remoteMentions, splitDomainMapping) = await ResolveNoteMentionsAsync(text); + + if (text != null) + text = await mentionsResolver.ResolveMentions(text, null, mentions, splitDomainMapping); + var actor = await userRenderer.RenderAsync(user); var note = new Note { @@ -55,14 +62,18 @@ public class NoteService( UserId = user.Id, CreatedAt = DateTime.UtcNow, UserHost = null, - Visibility = visibility + Visibility = visibility, + + Mentions = mentionedUserIds, + VisibleUserIds = visibility == Note.NoteVisibility.Specified ? mentionedUserIds : [], + MentionedRemoteUsers = remoteMentions, }; user.NotesCount++; await db.AddAsync(note); await db.SaveChangesAsync(); - var obj = await noteRenderer.RenderAsync(note); + var obj = await noteRenderer.RenderAsync(note, mentions); var activity = ActivityRenderer.RenderCreate(obj, actor); await deliverSvc.DeliverToFollowersAsync(activity, user); @@ -140,7 +151,6 @@ public class NoteService( UserHost = user.Host, Visibility = note.GetVisibility(actor), Reply = note.InReplyTo?.Id != null ? await ResolveNoteAsync(note.InReplyTo.Id) : null - //TODO: parse to fields for specified visibility & mentions }; if (dbNote.Text is { Length: > 100000 }) @@ -150,16 +160,19 @@ public class NoteService( dbNote.Mentions = mentionedUserIds; dbNote.MentionedRemoteUsers = remoteMentions; if (dbNote.Visibility == Note.NoteVisibility.Specified) { - var visibleUserIds = note.GetRecipients(actor).Concat(mentionedUserIds).ToList(); + var visibleUserIds = (await note.GetRecipients(actor) + .Select(async p => await userResolver.ResolveAsync(p)) + .AwaitAllNoConcurrencyAsync()) + .Select(p => p.Id) + .Concat(mentionedUserIds).ToList(); if (dbNote.ReplyUserId != null) visibleUserIds.Add(dbNote.ReplyUserId); dbNote.VisibleUserIds = visibleUserIds.Distinct().ToList(); } - dbNote.Text = - await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions, - splitDomainMapping); + dbNote.Text = await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions, + splitDomainMapping); } user.NotesCount++; @@ -175,7 +188,23 @@ public class NoteService( .Select(async p => await userResolver.ResolveAsync(p.Href!.Id!)) .AwaitAllNoConcurrencyAsync(); - var userIds = users.Select(p => p.Id).ToList(); + return ResolveNoteMentions(users); + } + + private async Task ResolveNoteMentionsAsync(string? text) { + var users = text != null + ? await MfmParser.Parse(text) + .SelectMany(p => p.Children.Append(p)) + .OfType() + .Select(async p => await userResolver.ResolveAsync(p.Acct)) + .AwaitAllNoConcurrencyAsync() + : []; + + return ResolveNoteMentions(users); + } + + private MentionQuad ResolveNoteMentions(IReadOnlyCollection users) { + var userIds = users.Select(p => p.Id).Distinct().ToList(); var remoteUsers = users.Where(p => p is { Host: not null, Uri: not null }) .ToList(); diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index fbe25a3d..2df45cde 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -27,11 +27,12 @@ public class UserService( DriveService driveSvc, MfmConverter mfmConverter ) { - private static (string Username, string? Host) AcctToTuple(string acct) { + private (string Username, string? Host) AcctToTuple(string acct) { if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query"); var split = acct[5..].Split('@'); - if (split.Length != 2) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query"); + if (split.Length != 2) + return (split[0], instance.Value.AccountDomain.ToPunycode()); return (split[0], split[1].ToPunycode()); }