[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]
|
[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)]
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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() =>
|
||||||
|
|
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);
|
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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue