[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.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<IActionResult> 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<IActionResult> 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);

View file

@ -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}";
}
}
}

View file

@ -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.InstanceSection> config, MfmConverter mfmConverter) {
public async Task<ASNote> RenderAsync(Note note) {
public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter, DatabaseContext db) {
public async Task<ASNote> RenderAsync(Note note, List<Note.MentionedUser>? 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<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.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 {
Id = id,
Content = note.Text != null ? await mfmConverter.ToHtmlAsync(note.Text, []) : null,
@ -42,7 +65,8 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
}
: null,
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.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,15 +160,18 @@ 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,
dbNote.Text = await mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, remoteMentions,
splitDomainMapping);
}
@ -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<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 })
.ToList();

View file

@ -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());
}