[backend/api] Add emoji pack importer
This commit is contained in:
parent
9bb5ef4366
commit
27f6e3790f
5 changed files with 137 additions and 6 deletions
|
@ -69,6 +69,7 @@ public class EmojiController(
|
|||
[HttpPost]
|
||||
[Authorize("role:admin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))]
|
||||
public async Task<IActionResult> UploadEmoji(IFormFile file, [FromServices] IOptions<Config.InstanceSection> config)
|
||||
{
|
||||
var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType,
|
||||
|
@ -104,6 +105,17 @@ public class EmojiController(
|
|||
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}")]
|
||||
[Authorize("role:admin")]
|
||||
[Consumes(MediaTypeNames.Application.Json)]
|
||||
|
|
|
@ -55,6 +55,7 @@ public static class ServiceExtensions
|
|||
.AddScoped<UserService>()
|
||||
.AddScoped<NoteService>()
|
||||
.AddScoped<EmojiService>()
|
||||
.AddScoped<EmojiImportService>()
|
||||
.AddScoped<WebFingerService>()
|
||||
.AddScoped<SystemUserService>()
|
||||
.AddScoped<DriveService>()
|
||||
|
|
|
@ -180,6 +180,9 @@ public class GracefulException(
|
|||
public static GracefulException RequestTimeout(string message, string? details = null) =>
|
||||
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 MisdirectedRequest() =>
|
||||
|
|
111
Iceshrimp.Backend/Core/Services/EmojiImportService.cs
Normal file
111
Iceshrimp.Backend/Core/Services/EmojiImportService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
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 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 name = fileName.Split(".")[0];
|
||||
|
||||
var existing = await db.Emojis.FirstOrDefaultAsync(p => p.Host == null && p.Name == name);
|
||||
|
||||
var id = IdHelpers.GenerateSlowflakeId();
|
||||
var emoji = new Emoji
|
||||
{
|
||||
Id = id,
|
||||
Name = existing == null && CustomEmojiRegex.IsMatch(name) ? name : id,
|
||||
Name = name,
|
||||
Aliases = aliases ?? [],
|
||||
Category = category,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
OriginalUrl = driveFile.Url,
|
||||
PublicUrl = driveFile.PublicUrl,
|
||||
|
|
Loading…
Add table
Reference in a new issue