[backend/federation] Handle emoji for incoming notes (ISH-89)

This commit is contained in:
Laura Hausmann 2024-02-24 22:59:13 +01:00
parent da63c26887
commit 69360a8ad7
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
9 changed files with 127 additions and 18 deletions

View file

@ -19,17 +19,17 @@ public class NoteRenderer(
public async Task<StatusEntity> RenderAsync(
Note note, User? user, List<AccountEntity>? accounts = null, List<MentionEntity>? mentions = null,
List<AttachmentEntity>? attachments = null, Dictionary<string, int>? likeCounts = null,
List<string>? likedNotes = null, List<string>? renotes = null, int recurse = 2
List<string>? likedNotes = null, List<string>? renotes = null, List<EmojiEntity>? emoji = null, int recurse = 2
)
{
var uri = note.Uri ?? note.GetPublicUri(config.Value);
var renote = note is { Renote: not null, IsQuote: false } && recurse > 0
? await RenderAsync(note.Renote, user, accounts, mentions, attachments, likeCounts, likedNotes,
renotes, 0)
renotes, emoji, 0)
: null;
var quote = note is { Renote: not null, IsQuote: true } && recurse > 0
? await RenderAsync(note.Renote, user, accounts, mentions, attachments, likeCounts, likedNotes,
renotes, --recurse)
renotes, emoji, --recurse)
: null;
var text = note.Text;
if (note is { Renote: not null, IsQuote: true } && text != null)
@ -44,6 +44,8 @@ public class NoteRenderer(
var renoted = renotes?.Contains(note.Id) ??
await db.Notes.AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote);
var noteEmoji = emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([note]);
if (mentions == null)
{
mentions = await db.Users.IncludeCommonProperties()
@ -119,7 +121,8 @@ public class NoteRenderer(
Text = text,
Mentions = mentions,
IsPinned = false,
Attachments = attachments
Attachments = attachments,
Emojis = noteEmoji
};
return res;
@ -184,6 +187,24 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<Note> notes)
{
var ids = notes.SelectMany(p => p.Emojis).ToList();
if (ids.Count == 0) return [];
return await db.Emojis
.Where(p => ids.Contains(p.Id))
.Select(p => new EmojiEntity
{
Id = p.Id,
Shortcode = p.Name,
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true
})
.ToListAsync();
}
public async Task<IEnumerable<StatusEntity>> RenderManyAsync(
IEnumerable<Note> notes, User? user, List<AccountEntity>? accounts = null
)
@ -200,8 +221,9 @@ public class NoteRenderer(
var likeCounts = await GetLikeCounts(noteList);
var likedNotes = await GetLikedNotes(noteList, user);
var renotes = await GetRenotes(noteList, user);
var emoji = await GetEmoji(noteList);
return await noteList.Select(p => RenderAsync(p, user, accounts, mentions, attachments, likeCounts, likedNotes,
renotes))
renotes, emoji))
.AwaitAllAsync();
}
}

View file

@ -0,0 +1,14 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class EmojiEntity
{
[JI] public required string Id;
[J("shortcode")] public required string Shortcode { get; set; }
[J("static_url")] public required string StaticUrl { get; set; }
[J("url")] public required string Url { get; set; }
[J("visible_in_picker")] public required bool VisibleInPicker { get; set; }
[J("category")] public string? Category { get; set; }
}

View file

@ -43,8 +43,8 @@ public class StatusEntity : IEntity
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
[J("emojis")] public object[] Emojis => []; //FIXME
[J("tags")] public object[] Tags => []; //FIXME
[J("reactions")] public object[] Reactions => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME

View file

@ -6,6 +6,7 @@ public static class Constants
public const string W3IdSecurityNs = "https://w3id.org/security";
public const string PurlDcNs = "http://purl.org/dc/terms";
public const string XsdNs = "http://www.w3.org/2001/XMLSchema";
public const string MastodonNs = "http://joinmastodon.org/ns";
public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"];
public static readonly string[] BrowserSafeMimeTypes =

View file

@ -30,7 +30,7 @@ public class Emoji
[Column("type")] [StringLength(64)] public string? Type { get; set; }
[Column("aliases", TypeName = "character varying(128)[]")]
public List<string> Aliases { get; set; } = null!;
public List<string> Aliases { get; set; } = [];
[Column("category")]
[StringLength(128)]

View file

@ -62,7 +62,6 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
var tags = mentions
.Select(mention => new ASMention
{
Type = $"{Constants.ActivityStreamsNs}#Mention",
Name = $"@{mention.Username}@{mention.Host}",
Href = new ASObjectBase(mention.Uri)
})

View file

@ -16,4 +16,4 @@ public class ASImage
public bool? Sensitive { get; set; }
}
public class ASImageConverter : ASSerializer.ListSingleObjectConverter<ASImage>;
public class ASImageConverter : ASSerializer.ListSingleObjectConverter<ASImage>;

View file

@ -3,6 +3,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
@ -20,17 +21,35 @@ public class ASTagLink : ASTag
public ASObjectBase? Href { get; set; }
[J($"{Constants.ActivityStreamsNs}#name")]
[JC(typeof(ValueObjectConverter))]
[JC(typeof(VC))]
public string? Name { get; set; }
}
public class ASMention : ASTagLink;
public class ASMention : ASTagLink
{
public ASMention() => Type = $"{Constants.ActivityStreamsNs}#Mention";
}
public class ASHashtag : ASTagLink;
public class ASHashtag : ASTagLink
{
public ASHashtag() => Type = $"{Constants.ActivityStreamsNs}#Hashtag";
}
public class ASEmoji : ASTag
{
//TODO
public ASEmoji() => Type = $"{Constants.MastodonNs}#Emoji";
[J($"{Constants.ActivityStreamsNs}#updated")]
[JC(typeof(VC))]
public DateTime? Updated { get; set; }
[J($"{Constants.ActivityStreamsNs}#icon")]
[JC(typeof(ASImageConverter))]
public ASImage? Image { get; set; }
[J($"{Constants.ActivityStreamsNs}#name")]
[JC(typeof(VC))]
public string? Name { get; set; }
}
public sealed class ASTagConverter : JsonConverter
@ -73,12 +92,15 @@ public sealed class ASTagConverter : JsonConverter
private ASTag? HandleObject(JToken obj)
{
var link = obj.ToObject<ASTagLink?>();
if (link is not { Href: not null }) return obj.ToObject<ASEmoji?>();
var tag = obj.ToObject<ASTag?>();
return link.Type == $"{Constants.ActivityStreamsNs}#Mention"
? obj.ToObject<ASMention?>()
: obj.ToObject<ASHashtag?>();
return tag?.Type switch
{
$"{Constants.ActivityStreamsNs}#Mention" => obj.ToObject<ASMention?>(),
$"{Constants.ActivityStreamsNs}#Hashtag" => obj.ToObject<ASHashtag?>(),
$"{Constants.MastodonNs}#Emoji" => obj.ToObject<ASEmoji?>(),
_ => null
};
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using AsyncKeyedLock;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -43,6 +44,12 @@ public class NoteService(
private readonly List<string> _resolverHistory = [];
private int _recursionLimit = 100;
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
{
o.PoolSize = 100;
o.PoolInitialFill = 5;
});
public async Task<Note> CreateNoteAsync(
User user, Note.NoteVisibility visibility, string? text = null, string? cw = null, Note? reply = null,
Note? renote = null, IReadOnlyCollection<DriveFile>? attachments = null
@ -313,6 +320,8 @@ public class NoteService(
throw GracefulException.UnprocessableEntity("User.Uri doesn't match Note.AttributedTo");
if (actor.Uri == null)
throw GracefulException.UnprocessableEntity("User.Uri is null");
if (actor.Host == null)
throw GracefulException.UnprocessableEntity("User.Host is null");
if (new Uri(note.Id).IdnHost != new Uri(actor.Uri).IdnHost)
throw GracefulException.UnprocessableEntity("User.Uri host doesn't match Note.Id host");
if (!note.Id.StartsWith("https://"))
@ -398,6 +407,9 @@ public class NoteService(
dbNote.AttachedFileTypes = files.Select(p => p.Type).ToList();
}
var emoji = await ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host);
dbNote.Emojis = emoji.Select(p => p.Id).ToList();
actor.NotesCount++;
if (dbNote.Reply != null) dbNote.Reply.RepliesCount++;
await db.Notes.AddAsync(dbNote);
@ -570,6 +582,45 @@ public class NoteService(
return (userIds, localUserIds, mentions, remoteMentions, splitDomainMapping);
}
private async Task<List<Emoji>> ProcessEmojiAsync(List<ASEmoji>? emoji, string host)
{
emoji?.RemoveAll(p => p.Name == null);
if (emoji is not { Count: > 0 }) return [];
foreach (var emojo in emoji) emojo.Name = emojo.Name?.Trim(':');
var known = await db.Emojis.Where(p => p.Host == host && emoji.Select(e => e.Name).Contains(p.Name))
.ToListAsync();
//TODO: handle updated emoji
foreach (var emojo in emoji.Where(emojo => known.All(p => p.Name != emojo.Name)))
{
using (await KeyedLocker.LockAsync($"emoji:{host}:{emojo.Name}"))
{
var dbEmojo = await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == emojo.Name);
if (dbEmojo == null)
{
dbEmojo = new Emoji
{
Id = IdHelpers.GenerateSlowflakeId(),
Host = host,
Name = emojo.Name ?? throw new Exception("emojo.Name must not be null at this stage"),
UpdatedAt = DateTime.UtcNow,
OriginalUrl = emojo.Image?.Url?.Link ?? throw new Exception("Emoji.Image has no url"),
PublicUrl = emojo.Image.Url.Link,
Uri = emojo.Id
};
await db.AddAsync(dbEmojo);
await db.SaveChangesAsync();
}
known.Add(dbEmojo);
}
}
return known;
}
private async Task<List<DriveFile>> ProcessAttachmentsAsync(
List<ASAttachment>? attachments, User user, bool sensitive
)