[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:
parent
235ec7457e
commit
67d1d776c8
36 changed files with 6597 additions and 109 deletions
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
5963
Iceshrimp.Backend/Core/Database/Migrations/20240210233409_FixNoteMentionsColumnType.Designer.cs
generated
Normal file
5963
Iceshrimp.Backend/Core/Database/Migrations/20240210233409_FixNoteMentionsColumnType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
|
|
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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>()
|
||||||
|
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>;
|
|
@ -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))]
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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(),
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
Loading…
Add table
Reference in a new issue