[backend/federation] Basic mentions handling (ISH-38)

This implementation adds handling of incoming mentions, including rewriting non-canonical mentions of split domain users into their canonical form when inserting notes into the database.
This commit is contained in:
Laura Hausmann 2024-02-10 19:05:12 +01:00
parent 235ec7457e
commit 67d1d776c8
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
36 changed files with 6597 additions and 109 deletions

View file

@ -1,18 +1,41 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
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.Extensions;
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.Controllers.Mastodon.Renderers; namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class NoteRenderer(IOptions<Config.InstanceSection> config, UserRenderer userRenderer) { public class NoteRenderer(
public async Task<Status> RenderAsync(Note note, int recurse = 2) { IOptions<Config.InstanceSection> config,
UserRenderer userRenderer,
MfmConverter mfmConverter,
DatabaseContext db
) {
public async Task<Status> RenderAsync(Note note, List<Mention>? mentions = null, int recurse = 2) {
var uri = note.Uri ?? $"https://{config.Value.WebDomain}/notes/{note.Id}"; var uri = note.Uri ?? $"https://{config.Value.WebDomain}/notes/{note.Id}";
var renote = note.Renote != null && recurse > 0 ? await RenderAsync(note.Renote, --recurse) : null; var renote = note.Renote != null && recurse > 0 ? await RenderAsync(note.Renote, mentions, --recurse) : null;
var text = note.Text; //TODO: append quote uri var text = note.Text; //TODO: append quote uri
var content = text != null ? await MfmConverter.ToHtmlAsync(text) : null; var content = text != null ? await mfmConverter.ToHtmlAsync(text, note.MentionedRemoteUsers) : null;
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}"
})
.ToListAsync();
}
else {
mentions = [..mentions.Where(p => note.Mentions.Contains(p.Id))];
}
var res = new Status { var res = new Status {
Id = note.Id, Id = note.Id,
@ -38,13 +61,30 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, UserRenderer
Visibility = Status.EncodeVisibility(note.Visibility), Visibility = Status.EncodeVisibility(note.Visibility),
Content = content, Content = content,
Text = text, Text = text,
Mentions = mentions,
IsPinned = false //FIXME IsPinned = false //FIXME
}; };
return res; return res;
} }
private async Task<List<Mention>> GetMentions(IReadOnlyCollection<Note> 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}"
})
.ToListAsync();
}
public async Task<IEnumerable<Status>> RenderManyAsync(IEnumerable<Note> notes) { public async Task<IEnumerable<Status>> RenderManyAsync(IEnumerable<Note> notes) {
return await notes.Select(RenderAsync).AwaitAllAsync(); var noteList = notes.ToList();
var mentions = await GetMentions(noteList);
return await noteList.Select(async p => await RenderAsync(p, mentions)).AwaitAllAsync();
} }
} }

View file

@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class UserRenderer(IOptions<Config.InstanceSection> config) { public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter) {
private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png"; private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png";
public async Task<Account> RenderAsync(User user, UserProfile? profile) { public async Task<Account> RenderAsync(User user, UserProfile? profile) {
@ -29,7 +29,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config) {
FollowersCount = user.FollowersCount, FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount, FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount, StatusesCount = user.NotesCount,
Note = await MfmConverter.ToHtmlAsync(profile?.Description ?? ""), Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", []),
Url = profile?.Url ?? user.Uri ?? $"https://{user.Host ?? config.Value.WebDomain}/@{user.Username}", Url = profile?.Url ?? user.Uri ?? $"https://{user.Host ?? config.Value.WebDomain}/@{user.Username}",
AvatarStaticUrl = user.AvatarUrl ?? _transparent, //TODO AvatarStaticUrl = user.AvatarUrl ?? _transparent, //TODO
HeaderUrl = user.BannerUrl ?? _transparent, HeaderUrl = user.BannerUrl ?? _transparent,

View file

@ -0,0 +1,10 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
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; }
}

View file

@ -40,9 +40,10 @@ public class Status : IEntity {
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required bool? IsPinned { get; set; } public required bool? IsPinned { get; set; }
[J("mentions")] public required List<Mention> Mentions { get; set; }
[J("emojis")] public object[] Emojis => []; //FIXME [J("emojis")] public object[] Emojis => []; //FIXME
[J("media_attachments")] public object[] Media => []; //FIXME [J("media_attachments")] public object[] Media => []; //FIXME
[J("mentions")] public object[] Mentions => []; //FIXME
[J("tags")] public object[] Tags => []; //FIXME [J("tags")] public object[] Tags => []; //FIXME
[J("reactions")] public object[] Reactions => []; //FIXME [J("reactions")] public object[] Reactions => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME [J("filtered")] public object[] Filtered => []; //FIXME

View file

@ -531,7 +531,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
entity.Property(e => e.FileIds).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.FileIds).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.HasPoll).HasDefaultValue(false); entity.Property(e => e.HasPoll).HasDefaultValue(false);
entity.Property(e => e.LocalOnly).HasDefaultValue(false); entity.Property(e => e.LocalOnly).HasDefaultValue(false);
entity.Property(e => e.MentionedRemoteUsers).HasDefaultValueSql("'[]'::text"); entity.Property(e => e.MentionedRemoteUsers).HasDefaultValueSql("'[]'::jsonb");
entity.Property(e => e.Mentions).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Mentions).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Reactions).HasDefaultValueSql("'{}'::jsonb"); entity.Property(e => e.Reactions).HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.RenoteCount).HasDefaultValue((short)0); entity.Property(e => e.RenoteCount).HasDefaultValue((short)0);

View file

@ -4418,7 +4418,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("location") .HasColumnName("location")
.HasComment("The location of the User."); .HasComment("The location of the User.");
b.Property<List<UserProfile.MentionedRemoteUsers>>("Mentions") b.Property<List<Note.MentionedUser>>("Mentions")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("jsonb") .HasColumnType("jsonb")

View file

@ -4455,7 +4455,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("location") .HasColumnName("location")
.HasComment("The location of the User."); .HasComment("The location of the User.");
b.Property<List<UserProfile.MentionedRemoteUsers>>("Mentions") b.Property<List<Note.MentionedUser>>("Mentions")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("jsonb") .HasColumnType("jsonb")

View file

@ -4454,7 +4454,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("location") .HasColumnName("location")
.HasComment("The location of the User."); .HasComment("The location of the User.");
b.Property<List<UserProfile.MentionedRemoteUsers>>("Mentions") b.Property<List<Note.MentionedUser>>("Mentions")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("jsonb") .HasColumnType("jsonb")

View file

@ -4454,7 +4454,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("location") .HasColumnName("location")
.HasComment("The location of the User."); .HasComment("The location of the User.");
b.Property<List<UserProfile.MentionedRemoteUsers>>("Mentions") b.Property<List<Note.MentionedUser>>("Mentions")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("jsonb") .HasColumnType("jsonb")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using Iceshrimp.Backend.Core.Database.Tables;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class FixNoteMentionsColumnType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" DROP DEFAULT;
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" TYPE jsonb USING "mentionedRemoteUsers"::jsonb;
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" SET DEFAULT '[]'::jsonb;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" DROP DEFAULT;
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" TYPE text USING "mentionedRemoteUsers"::text;
ALTER TABLE "note" ALTER COLUMN "mentionedRemoteUsers" SET DEFAULT '[]'::text;
""");
}
}
}

View file

@ -2417,12 +2417,12 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("localOnly"); .HasColumnName("localOnly");
b.Property<string>("MentionedRemoteUsers") b.Property<List<Note.MentionedUser>>("MentionedRemoteUsers")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("text") .HasColumnType("jsonb")
.HasColumnName("mentionedRemoteUsers") .HasColumnName("mentionedRemoteUsers")
.HasDefaultValueSql("'[]'::text"); .HasDefaultValueSql("'[]'::jsonb");
b.Property<List<string>>("Mentions") b.Property<List<string>>("Mentions")
.IsRequired() .IsRequired()
@ -4451,7 +4451,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("location") .HasColumnName("location")
.HasComment("The location of the User."); .HasComment("The location of the User.");
b.Property<List<UserProfile.MentionedRemoteUsers>>("Mentions") b.Property<List<Note.MentionedUser>>("Mentions")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("jsonb") .HasColumnType("jsonb")

View file

@ -5,6 +5,7 @@ using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NpgsqlTypes; using NpgsqlTypes;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Core.Database.Tables; namespace Iceshrimp.Backend.Core.Database.Tables;
@ -106,7 +107,8 @@ public class Note : IEntity {
[Column("mentions", TypeName = "character varying(32)[]")] [Column("mentions", TypeName = "character varying(32)[]")]
public List<string> Mentions { get; set; } = []; public List<string> Mentions { get; set; } = [];
[Column("mentionedRemoteUsers")] public string MentionedRemoteUsers { get; set; } = null!; [Column("mentionedRemoteUsers", TypeName = "jsonb")]
public List<Note.MentionedUser> MentionedRemoteUsers { get; set; } = [];
[Column("emojis", TypeName = "character varying(128)[]")] [Column("emojis", TypeName = "character varying(128)[]")]
public List<string> Emojis { get; set; } = []; public List<string> Emojis { get; set; } = [];
@ -271,4 +273,11 @@ public class Note : IEntity {
return this; return this;
} }
public class MentionedUser {
[J("uri")] public required string Uri { get; set; }
[J("url")] public string? Url { get; set; }
[J("username")] public required string Username { get; set; }
[J("host")] public required string Host { get; set; }
}
} }

View file

@ -157,7 +157,7 @@ public class UserProfile {
[Column("preventAiLearning")] public bool PreventAiLearning { get; set; } [Column("preventAiLearning")] public bool PreventAiLearning { get; set; }
[Column("mentions", TypeName = "jsonb")] [Column("mentions", TypeName = "jsonb")]
public List<MentionedRemoteUsers> Mentions { get; set; } = null!; public List<Note.MentionedUser> Mentions { get; set; } = null!;
[ForeignKey("PinnedPageId")] [ForeignKey("PinnedPageId")]
[InverseProperty(nameof(Page.UserProfile))] [InverseProperty(nameof(Page.UserProfile))]
@ -179,11 +179,4 @@ public class UserProfile {
[J("value")] public required string Value { get; set; } [J("value")] public required string Value { get; set; }
[J("verified")] public bool? IsVerified { get; set; } [J("verified")] public bool? IsVerified { get; set; }
} }
public class MentionedRemoteUsers {
[J("uri")] public required string Uri { get; set; }
[J("url")] public string? Url { get; set; }
[J("username")] public required string Username { get; set; }
[J("host")] public required string Host { get; set; }
}
} }

View file

@ -4,4 +4,13 @@ public static class EnumerableExtensions {
public static async Task<IEnumerable<T>> AwaitAllAsync<T>(this IEnumerable<Task<T>> tasks) { public static async Task<IEnumerable<T>> AwaitAllAsync<T>(this IEnumerable<Task<T>> tasks) {
return await Task.WhenAll(tasks); return await Task.WhenAll(tasks);
} }
public static async Task<List<T>> AwaitAllNoConcurrencyAsync<T>(this IEnumerable<Task<T>> tasks) {
var results = new List<T>();
foreach (var task in tasks) {
results.Add(await task);
}
return results;
}
} }

View file

@ -4,6 +4,7 @@ using Iceshrimp.Backend.Controllers.Schemas;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
@ -27,6 +28,7 @@ public static class ServiceExtensions {
.AddScoped<ActivityPub.NoteRenderer>() .AddScoped<ActivityPub.NoteRenderer>()
.AddScoped<ActivityPub.UserResolver>() .AddScoped<ActivityPub.UserResolver>()
.AddScoped<ActivityPub.ObjectResolver>() .AddScoped<ActivityPub.ObjectResolver>()
.AddScoped<ActivityPub.MentionsResolver>()
.AddScoped<ActivityPub.ActivityDeliverService>() .AddScoped<ActivityPub.ActivityDeliverService>()
.AddScoped<ActivityPub.FederationControlService>() .AddScoped<ActivityPub.FederationControlService>()
.AddScoped<ActivityPub.ActivityHandlerService>() .AddScoped<ActivityPub.ActivityHandlerService>()
@ -46,6 +48,7 @@ public static class ServiceExtensions {
// Singleton = instantiated once across application lifetime // Singleton = instantiated once across application lifetime
services services
.AddSingleton<HttpClient>() .AddSingleton<HttpClient>()
.AddSingleton<MfmConverter>()
.AddSingleton<HttpRequestService>() .AddSingleton<HttpRequestService>()
.AddSingleton<QueueService>() .AddSingleton<QueueService>()
.AddSingleton<ObjectStorageService>() .AddSingleton<ObjectStorageService>()

View file

@ -0,0 +1,83 @@
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;
using SplitDomainMapping = IReadOnlyDictionary<(string usernameLower, string webDomain), string>;
/// <summary>
/// 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.
/// </summary>
public class MentionsResolver(
DatabaseContext db,
IOptions<Config.InstanceSection> config,
IDistributedCache cache
) {
public async Task<string> ResolveMentions(
string mfm, string? host,
List<Note.MentionedUser> mentionCache,
SplitDomainMapping splitDomainMapping
) {
var nodes = MfmParser.Parse(mfm);
nodes = await ResolveMentions(nodes, host, mentionCache, splitDomainMapping);
return MfmSerializer.Serialize(nodes);
}
public async Task<IEnumerable<MfmNode>> ResolveMentions(
IEnumerable<MfmNode> nodes, string? host,
List<Note.MentionedUser> mentionCache,
SplitDomainMapping splitDomainMapping
) {
var nodesList = nodes.ToList();
foreach (var mention in nodesList.SelectMany(p => p.Children.Append(p)).OfType<MfmMentionNode>())
await ResolveMention(mention, host, mentionCache, splitDomainMapping);
return nodesList;
}
private async Task ResolveMention(
MfmMentionNode node, string? host,
IEnumerable<Note.MentionedUser> mentionCache,
SplitDomainMapping splitDomainMapping
) {
var finalHost = node.Host ?? host;
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;
var resolvedUser =
mentionCache.FirstOrDefault(p => string.Equals(p.Username, node.Username,
StringComparison.InvariantCultureIgnoreCase) &&
p.Host == finalHost);
if (resolvedUser != null) {
node.Username = resolvedUser.Username;
node.Host = resolvedUser.Host;
node.Acct = $"@{resolvedUser.Username}@{resolvedUser.Host}";
}
else {
async Task<string> 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.Acct = $"@{node.Username}";
}
}
}

View file

@ -6,30 +6,30 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub; namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
public class NoteRenderer(IOptions<Config.InstanceSection> config) { public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter) {
public async Task<ASNote> RenderAsync(Note note) { public async Task<ASNote> RenderAsync(Note note) {
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 ASIdObject($"https://{config.Value.WebDomain}/notes/{note.ReplyId}") ? new ASObjectBase($"https://{config.Value.WebDomain}/notes/{note.ReplyId}")
: null; : null;
List<ASIdObject> to = note.Visibility switch { List<ASObjectBase> 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 => [], // FIXME
_ => [] _ => []
}; };
List<ASIdObject> cc = note.Visibility switch { List<ASObjectBase> cc = note.Visibility switch {
Note.NoteVisibility.Home => [new ASLink($"{Constants.ActivityStreamsNs}#Public")], Note.NoteVisibility.Home => [new ASLink($"{Constants.ActivityStreamsNs}#Public")],
_ => [] _ => []
}; };
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,
AttributedTo = [new ASIdObject(userId)], AttributedTo = [new ASObjectBase(userId)],
Type = $"{Constants.ActivityStreamsNs}#Note", Type = $"{Constants.ActivityStreamsNs}#Note",
MkContent = note.Text, MkContent = note.Text,
PublishedAt = note.CreatedAt, PublishedAt = note.CreatedAt,

View file

@ -10,29 +10,29 @@ public class ObjectResolver(
DatabaseContext db, DatabaseContext db,
FederationControlService federationCtrl FederationControlService federationCtrl
) { ) {
public async Task<ASObject?> ResolveObject(ASIdObject idObj) { public async Task<ASObject?> ResolveObject(ASObjectBase baseObj) {
if (idObj is ASObject obj) return obj; if (baseObj is ASObject obj) return obj;
if (idObj.Id == null) { if (baseObj.Id == null) {
logger.LogDebug("Refusing to resolve object with null id property"); logger.LogDebug("Refusing to resolve object with null id property");
return null; return null;
} }
if (await federationCtrl.ShouldBlockAsync(idObj.Id)) { if (await federationCtrl.ShouldBlockAsync(baseObj.Id)) {
logger.LogDebug("Instance is blocked"); logger.LogDebug("Instance is blocked");
return null; return null;
} }
if (await db.Notes.AnyAsync(p => p.Uri == idObj.Id)) if (await db.Notes.AnyAsync(p => p.Uri == baseObj.Id))
return new ASNote { Id = idObj.Id }; return new ASNote { Id = baseObj.Id };
if (await db.Users.AnyAsync(p => p.Uri == idObj.Id)) if (await db.Users.AnyAsync(p => p.Uri == baseObj.Id))
return new ASActor { Id = idObj.Id }; return new ASActor { Id = baseObj.Id };
try { try {
var result = await fetchSvc.FetchActivityAsync(idObj.Id); var result = await fetchSvc.FetchActivityAsync(baseObj.Id);
return result.FirstOrDefault(); return result.FirstOrDefault();
} }
catch (Exception e) { catch (Exception e) {
logger.LogDebug("Failed to resolve object {id}: {error}", idObj.Id, e); logger.LogDebug("Failed to resolve object {id}: {error}", baseObj.Id, e);
return null; return null;
} }
} }

View file

@ -10,7 +10,7 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub; namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseContext db) { public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseContext db, MfmConverter mfmConverter) {
/// <summary> /// <summary>
/// This function is meant for compacting an actor into the @id form as specified in ActivityStreams /// This function is meant for compacting an actor into the @id form as specified in ActivityStreams
/// </summary> /// </summary>
@ -58,17 +58,17 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Url = new ASLink($"https://{config.Value.WebDomain}/@{user.Username}"), Url = new ASLink($"https://{config.Value.WebDomain}/@{user.Username}"),
Username = user.Username, Username = user.Username,
DisplayName = user.DisplayName ?? user.Username, DisplayName = user.DisplayName ?? user.Username,
Summary = profile?.Description != null ? await MfmConverter.FromHtmlAsync(profile.Description) : null, Summary = profile?.Description != null ? await mfmConverter.FromHtmlAsync(profile.Description) : null,
MkSummary = profile?.Description, MkSummary = profile?.Description,
IsCat = user.IsCat, IsCat = user.IsCat,
IsDiscoverable = user.IsExplorable, IsDiscoverable = user.IsExplorable,
IsLocked = user.IsLocked, IsLocked = user.IsLocked,
Endpoints = new ASEndpoints { Endpoints = new ASEndpoints {
SharedInbox = new ASIdObject($"https://{config.Value.WebDomain}/inbox") SharedInbox = new ASObjectBase($"https://{config.Value.WebDomain}/inbox")
}, },
PublicKey = new ASPublicKey { PublicKey = new ASPublicKey {
Id = $"{id}#main-key", Id = $"{id}#main-key",
Owner = new ASIdObject(id), Owner = new ASObjectBase(id),
PublicKey = keypair.PublicKey, PublicKey = keypair.PublicKey,
Type = "Key" Type = "Key"
} }

View file

@ -71,6 +71,10 @@ public class UserResolver(ILogger<UserResolver> logger, UserService userSvc, Web
return query; return query;
} }
public async Task<User> ResolveAsync(string username, string? host) {
return host != null ? await ResolveAsync($"acct:{username}@{host}") : await ResolveAsync($"acct:{username}");
}
public async Task<User> ResolveAsync(string query) { public async Task<User> ResolveAsync(string query) {
query = NormalizeQuery(query); query = NormalizeQuery(query);

View file

@ -6,7 +6,7 @@ using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectCo
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASCollection<T>() : ASIdObject where T : ASObject { public class ASCollection<T>() : ASObjectBase where T : ASObject {
public ASCollection(string id) : this() { public ASCollection(string id) : this() {
Id = id; Id = id;
} }

View file

@ -5,8 +5,8 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASEndpoints { public class ASEndpoints {
[J("https://www.w3.org/ns/activitystreams#sharedInbox")] [J("https://www.w3.org/ns/activitystreams#sharedInbox")]
[JC(typeof(LDIdObjectConverter))] [JC(typeof(ASObjectBaseConverter))]
public ASIdObject? SharedInbox { get; set; } public ASObjectBase? SharedInbox { get; set; }
} }
public class ASEndpointsConverter : ASSerializer.ListSingleObjectConverter<ASEndpoints>; public class ASEndpointsConverter : ASSerializer.ListSingleObjectConverter<ASEndpoints>;

View file

@ -1,12 +1,18 @@
using J = Newtonsoft.Json.JsonPropertyAttribute; using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASLink(string url) : ASIdObject(url) { public class ASLink(string url) : ASObjectBase(url) {
[J("https://www.w3.org/ns/activitystreams#href")] [J("https://www.w3.org/ns/activitystreams#href")]
public string? Href { get; set; } [JC(typeof(ASObjectBaseConverter))]
public ASObjectBase? Href { get; set; }
public string? Link => Id ?? Href; [J("https://www.w3.org/ns/activitystreams#name")]
[JC(typeof(ValueObjectConverter))]
public string? Name { get; set; }
public string? Link => Id ?? Href?.Id;
public override string? ToString() => Link; public override string? ToString() => Link;
} }

View file

@ -32,17 +32,21 @@ public class ASNote : ASObject {
public ASNoteSource? Source { get; set; } public ASNoteSource? Source { get; set; }
[J("https://www.w3.org/ns/activitystreams#to")] [J("https://www.w3.org/ns/activitystreams#to")]
public List<ASIdObject> To { get; set; } = []; public List<ASObjectBase> To { get; set; } = [];
[J("https://www.w3.org/ns/activitystreams#cc")] [J("https://www.w3.org/ns/activitystreams#cc")]
public List<ASIdObject> Cc { get; set; } = []; public List<ASObjectBase> Cc { get; set; } = [];
[J("https://www.w3.org/ns/activitystreams#attributedTo")] [J("https://www.w3.org/ns/activitystreams#attributedTo")]
public List<ASIdObject> AttributedTo { get; set; } = []; public List<ASObjectBase> AttributedTo { get; set; } = [];
[J("https://www.w3.org/ns/activitystreams#inReplyTo")] [J("https://www.w3.org/ns/activitystreams#inReplyTo")]
[JC(typeof(LDIdObjectConverter))] [JC(typeof(ASObjectBaseConverter))]
public ASIdObject? InReplyTo { get; set; } public ASObjectBase? InReplyTo { get; set; }
[J("https://www.w3.org/ns/activitystreams#tag")]
[JC(typeof(ASTagConverter))]
public List<ASTag>? Tags { get; set; }
public Note.NoteVisibility GetVisibility(ASActor actor) { public Note.NoteVisibility GetVisibility(ASActor actor) {
if (To.Any(p => p.Id == "https://www.w3.org/ns/activitystreams#Public")) if (To.Any(p => p.Id == "https://www.w3.org/ns/activitystreams#Public"))
@ -55,6 +59,17 @@ public class ASNote : ASObject {
return Note.NoteVisibility.Specified; return Note.NoteVisibility.Specified;
} }
public List<string> GetRecipients(ASActor actor) {
return To.Concat(Cc)
.Select(p => p.Id)
.Distinct()
.Where(p => p != $"{Constants.ActivityStreamsNs}#Public" &&
p != (actor.Followers?.Id ?? actor.Id + "/followers"))
.Where(p => p != null)
.Select(p => p!)
.ToList();
}
public new static class Types { public new static class Types {
private const string Ns = Constants.ActivityStreamsNs; private const string Ns = Constants.ActivityStreamsNs;

View file

@ -7,11 +7,11 @@ using JR = Newtonsoft.Json.JsonRequiredAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASObject : ASIdObject { public class ASObject : ASObjectBase {
[J("@id")] [JR] public new required string Id { get; set; } [J("@id")] [JR] public new required string Id { get; set; }
[J("@type")] [J("@type")]
[JC(typeof(LDTypeConverter))] [JC(typeof(StringListSingleConverter))]
public string? Type { get; set; } public string? Type { get; set; }
public bool IsUnresolved => GetType() == typeof(ASObject) && Type == null; public bool IsUnresolved => GetType() == typeof(ASObject) && Type == null;
@ -55,7 +55,7 @@ public class ASObject : ASIdObject {
public class ASTombstone : ASObject; public class ASTombstone : ASObject;
public sealed class LDTypeConverter : ASSerializer.ListSingleObjectConverter<string>; public sealed class StringListSingleConverter : ASSerializer.ListSingleObjectConverter<string>;
internal sealed class ASObjectConverter : JsonConverter { internal sealed class ASObjectConverter : JsonConverter {
public override bool CanWrite => false; public override bool CanWrite => false;

View file

@ -2,8 +2,8 @@ using J = Newtonsoft.Json.JsonPropertyAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASIdObject() { public class ASObjectBase() {
public ASIdObject(string? id) : this() { public ASObjectBase(string? id) : this() {
Id = id; Id = id;
} }
@ -14,4 +14,4 @@ public class ASIdObject() {
} }
} }
public sealed class LDIdObjectConverter : ASSerializer.ListSingleObjectConverter<ASIdObject>; public sealed class ASObjectBaseConverter : ASSerializer.ListSingleObjectConverter<ASObjectBase>;

View file

@ -6,8 +6,8 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASPublicKey : ASObject { public class ASPublicKey : ASObject {
[J("https://w3id.org/security#owner")] [J("https://w3id.org/security#owner")]
[JC(typeof(LDIdObjectConverter))] [JC(typeof(ASObjectBaseConverter))]
public ASIdObject? Owner { get; set; } public ASObjectBase? Owner { get; set; }
[J("https://w3id.org/security#publicKeyPem")] [J("https://w3id.org/security#publicKeyPem")]
[JC(typeof(VC))] [JC(typeof(VC))]

View file

@ -0,0 +1,75 @@
using Iceshrimp.Backend.Core.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASTag : ASObjectBase {
[J("@type")]
[JC(typeof(StringListSingleConverter))]
public string? Type { get; set; }
}
public class ASTagLink : ASTag {
[J("https://www.w3.org/ns/activitystreams#href")]
[JC(typeof(ASObjectBaseConverter))]
public ASObjectBase? Href { get; set; }
[J("https://www.w3.org/ns/activitystreams#name")]
[JC(typeof(ValueObjectConverter))]
public string? Name { get; set; }
}
public class ASMention : ASTagLink;
public class ASHashtag : ASTagLink;
public class ASEmoji : ASTag {
//TODO
}
public sealed class ASTagConverter : JsonConverter {
public override bool CanWrite => false;
public override bool CanConvert(Type objectType) {
return true;
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer) {
if (reader.TokenType == JsonToken.StartObject) {
var obj = JObject.Load(reader);
return HandleObject(obj);
}
if (reader.TokenType == JsonToken.StartArray) {
var array = JArray.Load(reader);
var result = new List<ASTag>();
foreach (var token in array) {
if (token is not JObject obj) return null;
var item = HandleObject(obj);
if (item == null) return null;
result.Add(item);
}
return result;
}
return null;
}
private ASTag? HandleObject(JToken obj) {
var link = obj.ToObject<ASTagLink?>();
if (link is not { Href: not null }) return obj.ToObject<ASEmoji?>();
return link.Type == $"{Constants.ActivityStreamsNs}#Mention"
? obj.ToObject<ASMention?>()
: obj.ToObject<ASHashtag?>();
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) {
throw new NotImplementedException();
}
}

View file

@ -4,15 +4,18 @@ using System.Web;
using AngleSharp; using AngleSharp;
using AngleSharp.Dom; using AngleSharp.Dom;
using AngleSharp.Html.Parser; using AngleSharp.Html.Parser;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
using static Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser; using Microsoft.Extensions.Options;
using MfmHtmlParser = Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser;
using HtmlParser = AngleSharp.Html.Parser.HtmlParser; using HtmlParser = AngleSharp.Html.Parser.HtmlParser;
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
public static class MfmConverter { public class MfmConverter(IOptions<Config.InstanceSection> config) {
public static async Task<string?> FromHtmlAsync(string? html) { public async Task<string?> FromHtmlAsync(string? html, List<Note.MentionedUser>? mentions = null) {
if (html == null) return null; if (html == null) return null;
// Ensure compatibility with AP servers that send both <br> as well as newlines // Ensure compatibility with AP servers that send both <br> as well as newlines
@ -23,45 +26,49 @@ public static class MfmConverter {
if (dom.Body == null) return ""; if (dom.Body == null) return "";
var sb = new StringBuilder(); var sb = new StringBuilder();
dom.Body.ChildNodes.Select(ParseNode).ToList().ForEach(s => sb.Append(s)); var parser = new MfmHtmlParser(mentions ?? []);
dom.Body.ChildNodes.Select(parser.ParseNode).ToList().ForEach(s => sb.Append(s));
return sb.ToString().Trim(); return sb.ToString().Trim();
} }
public static async Task<string> ToHtmlAsync(string mfm) { public async Task<string> ToHtmlAsync(IEnumerable<MfmNode> nodes, List<Note.MentionedUser> mentions) {
var nodes = MfmParser.Parse(mfm);
var context = BrowsingContext.New(); var context = BrowsingContext.New();
var document = await context.OpenNewAsync(); var document = await context.OpenNewAsync();
var element = document.CreateElement("p"); var element = document.CreateElement("p");
foreach (var node in nodes) element.AppendNodes(document.FromMfmNode(node)); foreach (var node in nodes) element.AppendNodes(FromMfmNode(document, node, mentions));
await using var sw = new StringWriter(); await using var sw = new StringWriter();
await element.ToHtmlAsync(sw); await element.ToHtmlAsync(sw);
return sw.ToString(); return sw.ToString();
} }
private static INode FromMfmNode(this IDocument document, MfmNode node) { public async Task<string> ToHtmlAsync(string mfm, List<Note.MentionedUser> mentions) {
var nodes = MfmParser.Parse(mfm);
return await ToHtmlAsync(nodes, mentions);
}
private INode FromMfmNode(IDocument document, MfmNode node, List<Note.MentionedUser> mentions) {
switch (node) { switch (node) {
case MfmBoldNode: { case MfmBoldNode: {
var el = document.CreateElement("b"); var el = document.CreateElement("b");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmSmallNode: { case MfmSmallNode: {
var el = document.CreateElement("small"); var el = document.CreateElement("small");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmStrikeNode: { case MfmStrikeNode: {
var el = document.CreateElement("del"); var el = document.CreateElement("del");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmItalicNode: case MfmItalicNode:
case MfmFnNode: { case MfmFnNode: {
var el = document.CreateElement("i"); var el = document.CreateElement("i");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmCodeBlockNode codeBlockNode: { case MfmCodeBlockNode codeBlockNode: {
@ -73,7 +80,7 @@ public static class MfmConverter {
} }
case MfmCenterNode: { case MfmCenterNode: {
var el = document.CreateElement("div"); var el = document.CreateElement("div");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmEmojiCodeNode emojiCodeNode: { case MfmEmojiCodeNode emojiCodeNode: {
@ -84,8 +91,7 @@ public static class MfmConverter {
} }
case MfmHashtagNode hashtagNode: { case MfmHashtagNode hashtagNode: {
var el = document.CreateElement("a"); var el = document.CreateElement("a");
//TODO: get url from config el.SetAttribute("href", $"https://{config.Value.WebDomain}/tags/{hashtagNode.Hashtag}");
el.SetAttribute("href", $"https://example.org/tags/{hashtagNode.Hashtag}");
el.TextContent = $"#{hashtagNode.Hashtag}"; el.TextContent = $"#{hashtagNode.Hashtag}";
el.SetAttribute("rel", "tag"); el.SetAttribute("rel", "tag");
return el; return el;
@ -108,18 +114,45 @@ public static class MfmConverter {
case MfmLinkNode linkNode: { case MfmLinkNode linkNode: {
var el = document.CreateElement("a"); var el = document.CreateElement("a");
el.SetAttribute("href", linkNode.Url); el.SetAttribute("href", linkNode.Url);
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmMentionNode mentionNode: { case MfmMentionNode mentionNode: {
var el = document.CreateElement("span"); var el = document.CreateElement("span");
if (mentionNode.Host == config.Value.AccountDomain || mentionNode.Host == config.Value.WebDomain)
mentionNode.Host = null;
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 (mention == null) {
el.TextContent = mentionNode.Acct; el.TextContent = mentionNode.Acct;
//TODO: Resolve mentions and only fall back to the above }
else {
el.ClassList.Add("h-card");
el.SetAttribute("translate", "no");
var a = document.CreateElement("a");
a.ClassList.Add("u-url", "mention");
a.SetAttribute("href", mention.Url ?? mention.Uri);
var span = document.CreateElement("span");
span.TextContent = $"@{mention.Username}";
a.AppendChild(span);
el.AppendChild(a);
}
return el; return el;
} }
case MfmQuoteNode: { case MfmQuoteNode: {
var el = document.CreateElement("blockquote"); var el = document.CreateElement("blockquote");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
case MfmTextNode textNode: { case MfmTextNode textNode: {
@ -155,7 +188,7 @@ public static class MfmConverter {
} }
case MfmPlainNode: { case MfmPlainNode: {
var el = document.CreateElement("span"); var el = document.CreateElement("span");
el.AppendChildren(document, node); AppendChildren(el, document, node, mentions);
return el; return el;
} }
default: { default: {
@ -164,7 +197,9 @@ public static class MfmConverter {
} }
} }
private static void AppendChildren(this INode element, IDocument document, MfmNode parent) { private void AppendChildren(INode element, IDocument document, MfmNode parent,
foreach (var node in parent.Children) element.AppendNodes(document.FromMfmNode(node)); List<Note.MentionedUser> mentions
) {
foreach (var node in parent.Children) element.AppendNodes(FromMfmNode(document, node, mentions));
} }
} }

View file

@ -1,9 +1,11 @@
using AngleSharp.Dom; using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using Iceshrimp.Backend.Core.Database.Tables;
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
internal static class HtmlParser { internal class HtmlParser(IEnumerable<Note.MentionedUser> mentions) {
internal static string? ParseNode(INode node) { internal string? ParseNode(INode node) {
if (node.NodeType is NodeType.Text) if (node.NodeType is NodeType.Text)
return node.TextContent; return node.TextContent;
if (node.NodeType is NodeType.Comment or NodeType.Document) if (node.NodeType is NodeType.Comment or NodeType.Document)
@ -14,8 +16,20 @@ internal static class HtmlParser {
return "\n"; return "\n";
} }
case "A": { case "A": {
//TODO: implement parsing of links & mentions (automatically correct split domain mentions for the latter) if (node is HtmlElement el) {
return null; var href = el.GetAttribute("href");
if (href == null) return $"<plain>{el.TextContent}</plain>";
if (el.ClassList.Contains("u-url") && el.ClassList.Contains("mention")) {
var mention = mentions.FirstOrDefault(p => p.Uri == href || p.Url == href);
if (mention != null) {
return $"@{mention.Username}@{mention.Host}";
}
}
return $"[{el.TextContent}]({href})";
}
return node.TextContent;
} }
case "H1": { case "H1": {
return $"【{ParseChildren(node)}】\n"; return $"【{ParseChildren(node)}】\n";
@ -74,7 +88,7 @@ internal static class HtmlParser {
} }
} }
private static string ParseChildren(INode node) { private string ParseChildren(INode node) {
return string.Join(null, node.ChildNodes.Select(ParseNode)); return string.Join(null, node.ChildNodes.Select(ParseNode));
} }
} }

View file

@ -1,10 +1,11 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
public static class MfmParser { public static class MfmParser {
private static readonly List<INodeParser> Parsers = [ private static readonly ImmutableList<INodeParser> Parsers = [
new PlainNodeParser(), new PlainNodeParser(),
new ItalicNodeParser(), new ItalicNodeParser(),
new BoldNodeParser(), new BoldNodeParser(),

View file

@ -0,0 +1,125 @@
using System.Text;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
namespace Iceshrimp.Backend.Core.Helpers.LibMfm.Serialization;
public static class MfmSerializer {
public static string Serialize(IEnumerable<MfmNode> nodes) {
var result = new StringBuilder();
foreach (var node in nodes) {
switch (node) {
case MfmCodeBlockNode mfmCodeBlockNode: {
result.Append($"```{mfmCodeBlockNode.Language ?? ""}\n");
result.Append(mfmCodeBlockNode.Code);
result.Append("```");
break;
}
case MfmMathBlockNode mfmMathBlockNode: {
result.Append(@"\[");
result.Append(mfmMathBlockNode.Formula);
result.Append(@"\]");
break;
}
case MfmSearchNode mfmSearchNode: {
throw new NotImplementedException();
break;
}
case MfmBoldNode mfmBoldNode: {
result.Append("**");
result.Append(Serialize(node.Children));
result.Append("**");
break;
}
case MfmCenterNode mfmCenterNode: {
result.Append("<center>");
result.Append(Serialize(node.Children));
result.Append("</center>");
break;
}
case MfmEmojiCodeNode mfmEmojiCodeNode: {
result.Append($":{mfmEmojiCodeNode.Name}:");
break;
}
case MfmFnNode mfmFnNode: {
throw new NotImplementedException();
break;
}
case MfmHashtagNode mfmHashtagNode: {
result.Append($"#{mfmHashtagNode.Hashtag}");
break;
}
case MfmInlineCodeNode mfmInlineCodeNode: {
result.Append($"`{mfmInlineCodeNode.Code}`");
break;
}
case MfmItalicNode mfmItalicNode: {
result.Append("~~");
result.Append(Serialize(node.Children));
result.Append("~~");
break;
}
case MfmLinkNode mfmLinkNode: {
if (mfmLinkNode.Silent) result.Append('?');
result.Append('[');
result.Append(Serialize(node.Children));
result.Append(']');
result.Append($"({mfmLinkNode.Url})");
break;
}
case MfmMathInlineNode mfmMathInlineNode: {
result.Append(@"\(");
result.Append(mfmMathInlineNode.Formula);
result.Append(@"\)");
break;
}
case MfmMentionNode mfmMentionNode: {
result.Append($"@{mfmMentionNode.Username}");
if (mfmMentionNode.Host != null)
result.Append($"@{mfmMentionNode.Host}");
break;
}
case MfmPlainNode: {
result.Append(node.Children.OfType<MfmTextNode>().Select(p => p.Text));
break;
}
case MfmSmallNode: {
result.Append("<small>");
result.Append(Serialize(node.Children));
result.Append("</small>");
break;
}
case MfmStrikeNode: {
result.Append("~~");
result.Append(Serialize(node.Children));
result.Append("~~");
break;
}
case MfmTextNode mfmTextNode: {
result.Append(mfmTextNode.Text);
break;
}
case MfmUnicodeEmojiNode mfmUnicodeEmojiNode: {
result.Append(mfmUnicodeEmojiNode.Emoji);
break;
}
case MfmUrlNode mfmUrlNode: {
if (mfmUrlNode.Brackets)
result.Append($"<{mfmUrlNode.Url}>");
else
result.Append(mfmUrlNode.Url);
break;
}
case MfmQuoteNode mfmQuoteNode: {
throw new NotImplementedException();
break;
}
default: {
throw new Exception("Unknown node type");
}
}
}
return result.ToString();
}
}

View file

@ -13,6 +13,12 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services; namespace Iceshrimp.Backend.Core.Services;
using MentionQuad =
(List<string> mentionedUserIds,
List<Note.MentionedUser> mentions,
List<Note.MentionedUser> remoteMentions,
Dictionary<(string usernameLower, string webDomain), string> splitDomainMapping);
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor", [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
Justification = "We need IOptionsSnapshot for config hot reload")] Justification = "We need IOptionsSnapshot for config hot reload")]
public class NoteService( public class NoteService(
@ -23,7 +29,9 @@ public class NoteService(
ActivityFetcherService fetchSvc, ActivityFetcherService fetchSvc,
ActivityDeliverService deliverSvc, ActivityDeliverService deliverSvc,
NoteRenderer noteRenderer, NoteRenderer noteRenderer,
UserRenderer userRenderer UserRenderer userRenderer,
MentionsResolver mentionsResolver,
MfmConverter mfmConverter
) { ) {
private readonly List<string> _resolverHistory = []; private readonly List<string> _resolverHistory = [];
private int _recursionLimit = 100; private int _recursionLimit = 100;
@ -117,14 +125,15 @@ public class NoteService(
if (user.IsSuspended) if (user.IsSuspended)
throw GracefulException.Forbidden("User is suspended"); throw GracefulException.Forbidden("User is suspended");
//TODO: validate AP object type
//TODO: resolve anything related to the note as well (attachments, emoji, etc) //TODO: resolve anything related to the note as well (attachments, emoji, etc)
var (mentionedUserIds, mentions, remoteMentions, splitDomainMapping) = await ResolveNoteMentionsAsync(note);
var dbNote = new Note { var dbNote = new Note {
Id = IdHelpers.GenerateSlowflakeId(), Id = IdHelpers.GenerateSlowflakeId(),
Uri = note.Id, Uri = note.Id,
Url = note.Url?.Id, //FIXME: this doesn't seem to work yet Url = note.Url?.Id, //FIXME: this doesn't seem to work yet
Text = note.MkContent ?? await MfmConverter.FromHtmlAsync(note.Content), Text = note.MkContent ?? await mfmConverter.FromHtmlAsync(note.Content, mentions),
UserId = user.Id, UserId = user.Id,
CreatedAt = note.PublishedAt?.ToUniversalTime() ?? CreatedAt = note.PublishedAt?.ToUniversalTime() ??
throw GracefulException.UnprocessableEntity("Missing or invalid PublishedAt field"), throw GracefulException.UnprocessableEntity("Missing or invalid PublishedAt field"),
@ -137,6 +146,22 @@ public class NoteService(
if (dbNote.Text is { Length: > 100000 }) if (dbNote.Text is { Length: > 100000 })
throw GracefulException.UnprocessableEntity("Content cannot be longer than 100.000 characters"); throw GracefulException.UnprocessableEntity("Content cannot be longer than 100.000 characters");
if (dbNote.Text is not null) {
dbNote.Mentions = mentionedUserIds;
dbNote.MentionedRemoteUsers = remoteMentions;
if (dbNote.Visibility == Note.NoteVisibility.Specified) {
var visibleUserIds = note.GetRecipients(actor).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);
}
user.NotesCount++; user.NotesCount++;
await db.Notes.AddAsync(dbNote); await db.Notes.AddAsync(dbNote);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -144,6 +169,43 @@ public class NoteService(
return dbNote; return dbNote;
} }
private async Task<MentionQuad> ResolveNoteMentionsAsync(ASNote note) {
var mentionTags = note.Tags?.OfType<ASMention>().Where(p => p.Href != null) ?? [];
var users = await mentionTags
.Select(async p => await userResolver.ResolveAsync(p.Href!.Id!))
.AwaitAllNoConcurrencyAsync();
var userIds = users.Select(p => p.Id).ToList();
var remoteUsers = users.Where(p => p is { Host: not null, Uri: not null })
.ToList();
var localUsers = users.Where(p => p.Host is null)
.ToList();
var splitDomainMapping = remoteUsers.Where(p => new Uri(p.Uri!).Host != p.Host)
.DistinctBy(p => p.Host)
.ToDictionary(p => (p.UsernameLower, new Uri(p.Uri!).Host), p => p.Host!);
var localMentions = localUsers.Select(p => new Note.MentionedUser {
Host = config.Value.AccountDomain,
Username = p.Username,
Uri = $"https://{config.Value.WebDomain}/users/{p.Id}",
Url = $"https://{config.Value.WebDomain}/@{p.Username}"
});
var remoteMentions = remoteUsers.Select(p => new Note.MentionedUser {
Host = p.Host!,
Uri = p.Uri!,
Username = p.Username,
Url = p.UserProfile?.Url
}).ToList();
var mentions = remoteMentions.Concat(localMentions).ToList();
return (userIds, mentions, remoteMentions, splitDomainMapping);
}
public async Task<Note?> ResolveNoteAsync(string uri) { public async Task<Note?> ResolveNoteAsync(string uri) {
//TODO: is this enough to prevent DoS attacks? //TODO: is this enough to prevent DoS attacks?
if (_recursionLimit-- <= 0) if (_recursionLimit-- <= 0)

View file

@ -24,9 +24,10 @@ public class UserService(
ILogger<UserService> logger, ILogger<UserService> logger,
DatabaseContext db, DatabaseContext db,
ActivityFetcherService fetchSvc, ActivityFetcherService fetchSvc,
DriveService driveSvc DriveService driveSvc,
MfmConverter mfmConverter
) { ) {
private (string Username, string? Host) AcctToTuple(string acct) { private static (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('@');
@ -43,7 +44,9 @@ public class UserService(
throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
} }
else { else {
return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == query); return await db.Users
.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Uri != null && p.Uri.ToLower() == query.ToLowerInvariant());
} }
var tuple = AcctToTuple(query); var tuple = AcctToTuple(query);
@ -51,7 +54,8 @@ public class UserService(
tuple.Host = null; tuple.Host = null;
return await db.Users return await db.Users
.IncludeCommonProperties() .IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Username == tuple.Username && p.Host == tuple.Host); .FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant() &&
p.Host == tuple.Host);
} }
public async Task<User> CreateUserAsync(string uri, string acct) { public async Task<User> CreateUserAsync(string uri, string acct) {
@ -93,7 +97,7 @@ public class UserService(
var profile = new UserProfile { var profile = new UserProfile {
User = user, User = user,
Description = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary), Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary),
//Birthday = TODO, //Birthday = TODO,
//Location = TODO, //Location = TODO,
//Fields = TODO, //Fields = TODO,
@ -151,7 +155,7 @@ public class UserService(
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor); var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
user.UserProfile.Description = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary); user.UserProfile.Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary);
//user.UserProfile.Birthday = TODO; //user.UserProfile.Birthday = TODO;
//user.UserProfile.Location = TODO; //user.UserProfile.Location = TODO;
//user.UserProfile.Fields = TODO; //user.UserProfile.Fields = TODO;

View file

@ -1,5 +1,6 @@
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.Parsing;
using Microsoft.Extensions.DependencyInjection;
namespace Iceshrimp.Tests.Parsing; namespace Iceshrimp.Tests.Parsing;
@ -24,8 +25,11 @@ public class MfmTests {
return; return;
async Task<double> Benchmark() { async Task<double> Benchmark() {
var provider = MockObjects.ServiceProvider;
var converter = provider.GetRequiredService<MfmConverter>();
var pre = DateTime.Now; var pre = DateTime.Now;
await MfmConverter.ToHtmlAsync(Mfm); await converter.ToHtmlAsync(Mfm, []);
var post = DateTime.Now; var post = DateTime.Now;
var ms = (post - pre).TotalMilliseconds; var ms = (post - pre).TotalMilliseconds;
Console.WriteLine($"Took {ms} ms"); Console.WriteLine($"Took {ms} ms");