[backend/api] Add emoji pack importer

This commit is contained in:
Kopper 2024-07-02 03:37:10 +03:00 committed by Iceshrimp development
parent 9bb5ef4366
commit 27f6e3790f
5 changed files with 137 additions and 6 deletions

View file

@ -69,6 +69,7 @@ public class EmojiController(
[HttpPost] [HttpPost]
[Authorize("role:admin")] [Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))]
public async Task<IActionResult> UploadEmoji(IFormFile file, [FromServices] IOptions<Config.InstanceSection> config) public async Task<IActionResult> UploadEmoji(IFormFile file, [FromServices] IOptions<Config.InstanceSection> config)
{ {
var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType, var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType,
@ -104,6 +105,17 @@ public class EmojiController(
return Ok(await emojiSvc.CloneEmoji(emojo)); return Ok(await emojiSvc.CloneEmoji(emojo));
} }
[HttpPost("import")]
[Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> ImportEmoji(IFormFile file, [FromServices] EmojiImportService emojiImportSvc)
{
var zip = await emojiImportSvc.Parse(file.OpenReadStream());
await emojiImportSvc.Import(zip); // TODO: run in background. this will take a while
return Accepted();
}
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize("role:admin")] [Authorize("role:admin")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]

View file

@ -55,6 +55,7 @@ public static class ServiceExtensions
.AddScoped<UserService>() .AddScoped<UserService>()
.AddScoped<NoteService>() .AddScoped<NoteService>()
.AddScoped<EmojiService>() .AddScoped<EmojiService>()
.AddScoped<EmojiImportService>()
.AddScoped<WebFingerService>() .AddScoped<WebFingerService>()
.AddScoped<SystemUserService>() .AddScoped<SystemUserService>()
.AddScoped<DriveService>() .AddScoped<DriveService>()

View file

@ -180,6 +180,9 @@ public class GracefulException(
public static GracefulException RequestTimeout(string message, string? details = null) => public static GracefulException RequestTimeout(string message, string? details = null) =>
new(HttpStatusCode.RequestTimeout, message, details); new(HttpStatusCode.RequestTimeout, message, details);
public static GracefulException Conflict(string message, string? details = null) =>
new(HttpStatusCode.Conflict, message, details);
public static GracefulException RecordNotFound() => new(HttpStatusCode.NotFound, "Record not found"); public static GracefulException RecordNotFound() => new(HttpStatusCode.NotFound, "Record not found");
public static GracefulException MisdirectedRequest() => public static GracefulException MisdirectedRequest() =>

View file

@ -0,0 +1,111 @@
using System.IO.Compression;
using System.Text.Json;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using static Iceshrimp.Backend.Core.Configuration.Config;
namespace Iceshrimp.Backend.Core.Services;
public class EmojiZipEmoji
{
public string? Name { get; set; }
public string? Category { get; set; }
public List<string> Aliases { get; set; } = [];
}
public class EmojiZipEntry
{
public required string FileName { get; set; }
public required EmojiZipEmoji Emoji { get; set; }
}
public class EmojiZipMeta
{
public required ushort MetaVersion { get; set; }
public required EmojiZipEntry[] Emojis { get; set; }
}
public record EmojiZip(EmojiZipMeta Metadata, ZipArchive Archive);
public class EmojiImportService(
EmojiService emojiSvc,
ILogger<EmojiImportService> logger,
IOptions<InstanceSection> config
)
{
public static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public async Task<EmojiZip> Parse(Stream zipStream)
{
var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
try
{
var meta = archive.GetEntry("meta.json")
?? throw GracefulException.BadRequest("Invalid emoji zip. Only Misskey-style emoji zips are supported.");
var metaJson = await JsonSerializer.DeserializeAsync<EmojiZipMeta>(meta.Open(), SerializerOptions)
?? throw GracefulException.BadRequest("Invalid emoji zip metadata");
if (metaJson.MetaVersion < 1 || metaJson.MetaVersion > 2)
throw GracefulException.BadRequest("Unrecognized metaVersion {version}, expected 1 or 2", metaJson.MetaVersion.ToString());
return new(metaJson, archive);
}
catch
{
// We don't want to dispose of archive on success, as Import will do it when it's done.
archive.Dispose();
throw;
}
}
public async Task Import(EmojiZip zip)
{
using var archive = zip.Archive;
var contentTypeProvider = new FileExtensionContentTypeProvider();
foreach (var emoji in zip.Metadata.Emojis)
{
var file = archive.GetEntry(emoji.FileName);
if (file == null)
{
logger.LogWarning("Skipping {file} as no such file was found in the zip.", emoji.FileName);
continue;
}
if (!contentTypeProvider.TryGetContentType(emoji.FileName, out var mimeType))
{
logger.LogWarning("Skipping {file} as the mime type could not be detemrined.", emoji.FileName);
continue;
}
// DriveService requires a seekable and .Length-able stream, which the DeflateStream from file.Open does not support.
using var buffer = new MemoryStream((int)file.Length);
await file.Open().CopyToAsync(buffer);
buffer.Seek(0, SeekOrigin.Begin);
var name = emoji.Emoji.Name ?? emoji.FileName;
try
{
await emojiSvc.CreateEmojiFromStream(
buffer,
name,
mimeType,
config.Value,
emoji.Emoji.Aliases,
emoji.Emoji.Category
);
logger.LogDebug("Imported emoji {emoji}", name);
}
catch (GracefulException e) when (e.StatusCode == System.Net.HttpStatusCode.Conflict)
{
logger.LogDebug("Skipping {emoji} as it already exists.", name);
}
}
}
}

View file

@ -26,9 +26,15 @@ public partial class EmojiService(DatabaseContext db, DriveService driveSvc, Sys
new(@"^:?([\w+-]+)@([a-zA-Z0-9._\-]+\.[a-zA-Z0-9._\-]+):?$", RegexOptions.Compiled); new(@"^:?([\w+-]+)@([a-zA-Z0-9._\-]+\.[a-zA-Z0-9._\-]+):?$", RegexOptions.Compiled);
public async Task<Emoji> CreateEmojiFromStream( public async Task<Emoji> CreateEmojiFromStream(
Stream input, string fileName, string mimeType, Config.InstanceSection config Stream input, string fileName, string mimeType, Config.InstanceSection config, 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 user = await sysUserSvc.GetInstanceActorAsync();
var request = new DriveFileCreationRequest var request = new DriveFileCreationRequest
{ {
@ -38,15 +44,13 @@ public partial class EmojiService(DatabaseContext db, DriveService driveSvc, Sys
}; };
var driveFile = await driveSvc.StoreFile(input, user, request); var driveFile = await driveSvc.StoreFile(input, user, request);
var name = fileName.Split(".")[0];
var existing = await db.Emojis.FirstOrDefaultAsync(p => p.Host == null && p.Name == name);
var id = IdHelpers.GenerateSlowflakeId(); var id = IdHelpers.GenerateSlowflakeId();
var emoji = new Emoji var emoji = new Emoji
{ {
Id = id, Id = id,
Name = existing == null && CustomEmojiRegex.IsMatch(name) ? name : id, Name = name,
Aliases = aliases ?? [],
Category = category,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
OriginalUrl = driveFile.Url, OriginalUrl = driveFile.Url,
PublicUrl = driveFile.PublicUrl, PublicUrl = driveFile.PublicUrl,