[backend/federation] Handle outgoing mentions correctly (ISH-46)

This commit is contained in:
Laura Hausmann 2024-02-12 04:24:09 +01:00
parent e6079da2ab
commit 16d0199506
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
5 changed files with 76 additions and 20 deletions

View file

@ -3,6 +3,7 @@ using System.Text;
using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Attributes;
using Iceshrimp.Backend.Controllers.Schemas; using Iceshrimp.Backend.Controllers.Schemas;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
@ -27,9 +28,9 @@ public class ActivityPubController : Controller {
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetNote(string id, [FromServices] DatabaseContext db, public async Task<IActionResult> GetNote(string id, [FromServices] DatabaseContext db,
[FromServices] NoteRenderer noteRenderer) { [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(); if (note == null) return NotFound();
var rendered = noteRenderer.RenderAsync(note); var rendered = await noteRenderer.RenderAsync(note);
var compacted = LdHelpers.Compact(rendered); var compacted = LdHelpers.Compact(rendered);
return Ok(compacted); return Ok(compacted);
} }
@ -43,7 +44,7 @@ public class ActivityPubController : Controller {
public async Task<IActionResult> GetUser(string id, public async Task<IActionResult> GetUser(string id,
[FromServices] DatabaseContext db, [FromServices] DatabaseContext db,
[FromServices] UserRenderer userRenderer) { [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(); if (user == null) return NotFound();
var rendered = await userRenderer.RenderAsync(user); var rendered = await userRenderer.RenderAsync(user);
var compacted = LdHelpers.Compact(rendered); var compacted = LdHelpers.Compact(rendered);

View file

@ -77,7 +77,8 @@ public class MentionsResolver(
node.Username = await cache.FetchAsync($"localUserNameCapitalization:{node.Username.ToLowerInvariant()}", node.Username = await cache.FetchAsync($"localUserNameCapitalization:{node.Username.ToLowerInvariant()}",
TimeSpan.FromHours(24), FetchLocalUserCapitalization); TimeSpan.FromHours(24), FetchLocalUserCapitalization);
node.Acct = $"@{node.Username}"; node.Host = config.Value.AccountDomain;
node.Acct = $"@{node.Username}@{config.Value.AccountDomain}";
} }
} }
} }

View file

@ -1,23 +1,37 @@
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub; namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter) { public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter, DatabaseContext db) {
public async Task<ASNote> RenderAsync(Note note) { public async Task<ASNote> RenderAsync(Note note, List<Note.MentionedUser>? mentions = null) {
var id = $"https://{config.Value.WebDomain}/notes/{note.Id}"; var id = $"https://{config.Value.WebDomain}/notes/{note.Id}";
var userId = $"https://{config.Value.WebDomain}/users/{note.User.Id}"; var userId = $"https://{config.Value.WebDomain}/users/{note.User.Id}";
var replyId = note.ReplyId != null var replyId = note.ReplyId != null
? new ASObjectBase($"https://{config.Value.WebDomain}/notes/{note.ReplyId}") ? new ASObjectBase($"https://{config.Value.WebDomain}/notes/{note.ReplyId}")
: null; : null;
List<ASObjectBase> 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.Public => [new ASLink($"{Constants.ActivityStreamsNs}#Public")],
Note.NoteVisibility.Followers => [new ASLink($"{userId}/followers")], 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.InstanceSection> config, MfmConverter
_ => [] _ => []
}; };
var tags = mentions
.Select(mention => new ASMention {
Type = $"{Constants.ActivityStreamsNs}#Mention",
Name = $"@{mention.Username}@{mention.Host}",
Href = new ASObjectBase(mention.Uri)
})
.Cast<ASTag>()
.ToList();
return new ASNote { return new ASNote {
Id = id, Id = id,
Content = note.Text != null ? await mfmConverter.ToHtmlAsync(note.Text, []) : null, Content = note.Text != null ? await mfmConverter.ToHtmlAsync(note.Text, []) : null,
@ -42,7 +65,8 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
} }
: null, : null,
Cc = cc, Cc = cc,
To = to To = to,
Tags = tags
}; };
} }
} }

View file

@ -7,6 +7,8 @@ using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; 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 Iceshrimp.Backend.Core.Middleware;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -44,6 +46,11 @@ public class NoteService(
if (text is { Length: > 100000 }) if (text is { Length: > 100000 })
throw GracefulException.BadRequest("Text cannot be longer than 100.000 characters"); 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 actor = await userRenderer.RenderAsync(user);
var note = new Note { var note = new Note {
@ -55,14 +62,18 @@ public class NoteService(
UserId = user.Id, UserId = user.Id,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UserHost = null, UserHost = null,
Visibility = visibility Visibility = visibility,
Mentions = mentionedUserIds,
VisibleUserIds = visibility == Note.NoteVisibility.Specified ? mentionedUserIds : [],
MentionedRemoteUsers = remoteMentions,
}; };
user.NotesCount++; user.NotesCount++;
await db.AddAsync(note); await db.AddAsync(note);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var obj = await noteRenderer.RenderAsync(note); var obj = await noteRenderer.RenderAsync(note, mentions);
var activity = ActivityRenderer.RenderCreate(obj, actor); var activity = ActivityRenderer.RenderCreate(obj, actor);
await deliverSvc.DeliverToFollowersAsync(activity, user); await deliverSvc.DeliverToFollowersAsync(activity, user);
@ -140,7 +151,6 @@ public class NoteService(
UserHost = user.Host, UserHost = user.Host,
Visibility = note.GetVisibility(actor), Visibility = note.GetVisibility(actor),
Reply = note.InReplyTo?.Id != null ? await ResolveNoteAsync(note.InReplyTo.Id) : null 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 }) if (dbNote.Text is { Length: > 100000 })
@ -150,15 +160,18 @@ public class NoteService(
dbNote.Mentions = mentionedUserIds; dbNote.Mentions = mentionedUserIds;
dbNote.MentionedRemoteUsers = remoteMentions; dbNote.MentionedRemoteUsers = remoteMentions;
if (dbNote.Visibility == Note.NoteVisibility.Specified) { 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) if (dbNote.ReplyUserId != null)
visibleUserIds.Add(dbNote.ReplyUserId); visibleUserIds.Add(dbNote.ReplyUserId);
dbNote.VisibleUserIds = visibleUserIds.Distinct().ToList(); dbNote.VisibleUserIds = visibleUserIds.Distinct().ToList();
} }
dbNote.Text = dbNote.Text = await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions,
await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions,
splitDomainMapping); splitDomainMapping);
} }
@ -175,7 +188,23 @@ public class NoteService(
.Select(async p => await userResolver.ResolveAsync(p.Href!.Id!)) .Select(async p => await userResolver.ResolveAsync(p.Href!.Id!))
.AwaitAllNoConcurrencyAsync(); .AwaitAllNoConcurrencyAsync();
var userIds = users.Select(p => p.Id).ToList(); return ResolveNoteMentions(users);
}
private async Task<MentionQuad> ResolveNoteMentionsAsync(string? text) {
var users = text != null
? await MfmParser.Parse(text)
.SelectMany(p => p.Children.Append(p))
.OfType<MfmMentionNode>()
.Select(async p => await userResolver.ResolveAsync(p.Acct))
.AwaitAllNoConcurrencyAsync()
: [];
return ResolveNoteMentions(users);
}
private MentionQuad ResolveNoteMentions(IReadOnlyCollection<User> users) {
var userIds = users.Select(p => p.Id).Distinct().ToList();
var remoteUsers = users.Where(p => p is { Host: not null, Uri: not null }) var remoteUsers = users.Where(p => p is { Host: not null, Uri: not null })
.ToList(); .ToList();

View file

@ -27,11 +27,12 @@ public class UserService(
DriveService driveSvc, DriveService driveSvc,
MfmConverter mfmConverter 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"); if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query");
var split = acct[5..].Split('@'); 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()); return (split[0], split[1].ToPunycode());
} }