248 lines
No EOL
8 KiB
C#
248 lines
No EOL
8 KiB
C#
using System.Text.RegularExpressions;
|
|
using AsyncKeyedLock;
|
|
using Iceshrimp.Backend.Core.Configuration;
|
|
using Iceshrimp.Backend.Core.Database;
|
|
using Iceshrimp.Backend.Core.Database.Tables;
|
|
using Iceshrimp.Backend.Core.Extensions;
|
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
|
using Iceshrimp.Backend.Core.Helpers;
|
|
using Iceshrimp.Backend.Core.Middleware;
|
|
using Iceshrimp.Parsing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Iceshrimp.Backend.Core.Services;
|
|
|
|
public partial class EmojiService(
|
|
DatabaseContext db,
|
|
DriveService driveSvc,
|
|
SystemUserService sysUserSvc,
|
|
IOptions<Config.InstanceSection> config
|
|
) : IScopedService
|
|
{
|
|
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
|
|
{
|
|
o.PoolSize = 100;
|
|
o.PoolInitialFill = 5;
|
|
});
|
|
|
|
public async Task<Emoji> CreateEmojiFromStreamAsync(
|
|
Stream input, string fileName, string mimeType, List<string>? aliases = null,
|
|
string? category = null
|
|
)
|
|
{
|
|
var name = fileName.Split(".")[0];
|
|
var existing = await db.Emojis.AnyAsync(p => p.Host == null && p.Name == name);
|
|
if (existing)
|
|
throw GracefulException.Conflict("An emoji with that name already exists.");
|
|
|
|
var user = await sysUserSvc.GetInstanceActorAsync();
|
|
var request = new DriveFileCreationRequest
|
|
{
|
|
Filename = fileName,
|
|
MimeType = mimeType,
|
|
IsSensitive = false
|
|
};
|
|
var driveFile = await driveSvc.StoreFileAsync(input, user, request, true);
|
|
|
|
var id = IdHelpers.GenerateSnowflakeId();
|
|
var emoji = new Emoji
|
|
{
|
|
Id = id,
|
|
Name = name,
|
|
Aliases = aliases ?? [],
|
|
Category = category,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
OriginalUrl = driveFile.Url,
|
|
PublicUrl = driveFile.AccessUrl,
|
|
Width = driveFile.Properties.Width,
|
|
Height = driveFile.Properties.Height,
|
|
Sensitive = false
|
|
};
|
|
emoji.Uri = emoji.GetPublicUri(config.Value);
|
|
|
|
await db.AddAsync(emoji);
|
|
await db.SaveChangesAsync();
|
|
|
|
return emoji;
|
|
}
|
|
|
|
public async Task<Emoji> CloneEmojiAsync(Emoji existing)
|
|
{
|
|
var user = await sysUserSvc.GetInstanceActorAsync();
|
|
var driveFile = await driveSvc.StoreFileAsync(existing.OriginalUrl, user, false, forceStore: true,
|
|
skipImageProcessing: false) ??
|
|
throw new Exception("Error storing emoji file");
|
|
|
|
var emoji = new Emoji
|
|
{
|
|
Id = IdHelpers.GenerateSnowflakeId(),
|
|
Name = existing.Name,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
OriginalUrl = driveFile.Url,
|
|
PublicUrl = driveFile.AccessUrl,
|
|
Width = driveFile.Properties.Width,
|
|
Height = driveFile.Properties.Height,
|
|
Sensitive = existing.Sensitive
|
|
};
|
|
emoji.Uri = emoji.GetPublicUri(config.Value);
|
|
|
|
await db.AddAsync(emoji);
|
|
await db.SaveChangesAsync();
|
|
|
|
return emoji;
|
|
}
|
|
|
|
public async Task DeleteEmojiAsync(string id)
|
|
{
|
|
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Host == null && p.Id == id);
|
|
if (emoji == null) throw GracefulException.NotFound("Emoji not found");
|
|
|
|
var driveFile = await db.DriveFiles.FirstOrDefaultAsync(p => p.Url == emoji.OriginalUrl);
|
|
if (driveFile != null) await driveSvc.RemoveFileAsync(driveFile.Id);
|
|
|
|
db.Remove(emoji);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public 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(':');
|
|
host = host.ToPunycodeLower();
|
|
|
|
var resolved = 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 => resolved.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.GenerateSnowflakeId(),
|
|
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,
|
|
Sensitive = false
|
|
};
|
|
await db.AddAsync(dbEmojo);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
resolved.Add(dbEmojo);
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
// This is technically the unicode character 'heavy black heart', but misskey doesn't send the emoji version selector, so here we are.
|
|
private const string MisskeyHeart = "\u2764";
|
|
private const string EmojiVersionSelector = "\ufe0f";
|
|
|
|
public async Task<string> ResolveEmojiNameAsync(string name, string? host)
|
|
{
|
|
if (name == MisskeyHeart)
|
|
return name + EmojiVersionSelector;
|
|
if (EmojiHelpers.IsEmoji(name))
|
|
return name;
|
|
|
|
host = host?.ToPunycodeLower();
|
|
var match = CustomEmojiRegex.Match(name);
|
|
var remoteMatch = RemoteCustomEmojiRegex.Match(name);
|
|
var localMatchSuccess = !match.Success || match.Groups.Count != 2;
|
|
if (localMatchSuccess && !remoteMatch.Success)
|
|
throw GracefulException.BadRequest("Invalid emoji name");
|
|
|
|
// @formatter:off
|
|
var hit = !remoteMatch.Success
|
|
? await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == match.Groups[1].Value)
|
|
: await db.Emojis.FirstOrDefaultAsync(p => p.Name == remoteMatch.Groups[1].Value &&
|
|
p.Host == remoteMatch.Groups[2].Value.ToPunycodeLower());
|
|
// @formatter:on
|
|
|
|
if (hit == null)
|
|
throw GracefulException.BadRequest("Unknown emoji");
|
|
|
|
return hit.Host == null ? $":{hit.Name}:" : $":{hit.Name}@{hit.Host}:";
|
|
}
|
|
|
|
public async Task<Emoji?> ResolveEmojiAsync(string fqn)
|
|
{
|
|
if (!fqn.StartsWith(':')) return null;
|
|
var split = fqn.Trim(':').Split('@');
|
|
var name = split[0];
|
|
var host = split.Length > 1 ? split[1].ToPunycodeLower() : null;
|
|
|
|
return await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == name);
|
|
}
|
|
|
|
public async Task<List<Emoji>> ResolveEmojiAsync(IEnumerable<MfmNodeTypes.MfmNode> nodes)
|
|
{
|
|
var list = new List<MfmNodeTypes.MfmEmojiCodeNode>();
|
|
ResolveChildren(nodes, ref list);
|
|
return await db.Emojis.Where(p => p.Host == null && list.Select(i => i.Name).Contains(p.Name)).ToListAsync();
|
|
}
|
|
|
|
private static void ResolveChildren(
|
|
IEnumerable<MfmNodeTypes.MfmNode> nodes, ref List<MfmNodeTypes.MfmEmojiCodeNode> list
|
|
)
|
|
{
|
|
foreach (var node in nodes)
|
|
{
|
|
if (node is MfmNodeTypes.MfmEmojiCodeNode emojiNode) list.Add(emojiNode);
|
|
list.AddRange(node.Children.OfType<MfmNodeTypes.MfmEmojiCodeNode>());
|
|
ResolveChildren(node.Children, ref list);
|
|
}
|
|
}
|
|
|
|
public async Task<Emoji?> UpdateLocalEmojiAsync(
|
|
string id, string? name, List<string>? aliases, string? category, string? license, bool? sensitive
|
|
)
|
|
{
|
|
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id);
|
|
if (emoji == null) return null;
|
|
if (emoji.Host != null) return null;
|
|
|
|
emoji.UpdatedAt = DateTime.UtcNow;
|
|
|
|
var existing = await db.Emojis.FirstOrDefaultAsync(p => p.Host == null && p.Name == name);
|
|
|
|
if (name != null && existing == null && CustomEmojiRegex.IsMatch(name))
|
|
{
|
|
emoji.Name = name;
|
|
emoji.Uri = emoji.GetPublicUri(config.Value);
|
|
}
|
|
|
|
if (aliases != null) emoji.Aliases = aliases;
|
|
|
|
// If category is provided but empty reset to null
|
|
if (category != null) emoji.Category = string.IsNullOrEmpty(category) ? null : category;
|
|
|
|
if (license != null) emoji.License = license;
|
|
|
|
if (sensitive.HasValue) emoji.Sensitive = sensitive.Value;
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
return emoji;
|
|
}
|
|
|
|
public static bool IsCustomEmoji(string s) => CustomEmojiRegex.IsMatch(s) || RemoteCustomEmojiRegex.IsMatch(s);
|
|
|
|
[GeneratedRegex(@"^:?([\w+-]+)(?:@\.)?:?$", RegexOptions.Compiled)]
|
|
private static partial Regex CustomEmojiRegex { get; }
|
|
|
|
[GeneratedRegex(@"^:?([\w+-]+)@([a-zA-Z0-9._\-]+\.[a-zA-Z0-9._\-]+):?$", RegexOptions.Compiled)]
|
|
private static partial Regex RemoteCustomEmojiRegex { get; }
|
|
} |