diff --git a/.husky/pre-commit b/.husky/pre-commit
index 8047c2c5..bc8bed76 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -12,7 +12,7 @@ if [ -n "$(find "flake.lock" -mtime +7 -print)" ]; then
nix-shell $0
exit $?
else
- nix flake update --extra-experimental-features 'nix-command flakes'
+ nix flake update --extra-experimental-features 'nix-command flakes' -vL
git add flake.lock
fi
else
@@ -22,4 +22,4 @@ else
echo "Nix flake lock was updated less than 7 days ago. Skipping update."
fi
-npx -y lint-staged
+npx -y lint-staged
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs b/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs
new file mode 100644
index 00000000..e0914eaa
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs
@@ -0,0 +1,19 @@
+namespace Spacebar.AdminApi.Models;
+
+public class StickerModel {
+ public string Id { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public string? Description { get; set; }
+ public bool? Available { get; set; }
+ public string? Tags { get; set; }
+ public string? PackId { get; set; }
+ public string? GuildId { get; set; }
+ public string? UserId { get; set; }
+ public int Type { get; set; }
+ public int FormatType { get; set; }
+ // public virtual Guild? Guild { get; set; }
+ // public virtual StickerPack? Pack { get; set; }
+ // public virtual ICollection<StickerPack> StickerPacks { get; set; } = new List<StickerPack>();
+ // public virtual User? User { get; set; }
+ // public virtual ICollection<Message> Messages { get; set; } = new List<Message>();
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs
new file mode 100644
index 00000000..25070052
--- /dev/null
+++ b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs
@@ -0,0 +1,39 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Spacebar.AdminApi.Extensions;
+using Spacebar.AdminApi.Models;
+using Spacebar.AdminApi.Services;
+using Spacebar.Db.Contexts;
+using Spacebar.Db.Models;
+using Spacebar.RabbitMqUtilities;
+
+namespace Spacebar.AdminApi.Controllers.Media;
+
+[ApiController]
+[Route("/media/sticker")]
+public class StickerController(ILogger<StickerController> logger, SpacebarDbContext db, RabbitMQService mq, AuthenticationService auth, IServiceProvider sp) : ControllerBase {
+ [HttpGet("")]
+ public async IAsyncEnumerable<StickerModel> GetStickers() {
+ (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
+
+ // var db2 = sp.CreateScope().ServiceProvider.GetService<SpacebarDbContext>();
+ var stickers = db.Stickers
+ .AsNoTracking()
+ .IgnoreAutoIncludes()
+ .AsAsyncEnumerable();
+ await foreach (var sticker in stickers) {
+ yield return new() {
+ Id = sticker.Id,
+ Name = sticker.Name,
+ Description = sticker.Description,
+ Available = sticker.Available,
+ Tags = sticker.Tags,
+ PackId = sticker.PackId,
+ GuildId = sticker.GuildId,
+ UserId = sticker.UserId,
+ Type = sticker.Type,
+ FormatType = sticker.FormatType,
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs b/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs
new file mode 100644
index 00000000..1de9f632
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs
@@ -0,0 +1,59 @@
+using System.Configuration;
+using System.Net.Http.Headers;
+using ArcaneLibs.Extensions.Streams;
+using ImageMagick;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Build.Globbing;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
+using Spacebar.AdminApi.TestClient.Services.Services;
+
+namespace Spacebar.AdminApi.TestClient.Services.Controllers;
+
+[ApiController]
+public class GetImageController(LruFileCache lfc, IFileSource fs, DiscordImageResizeService dirs) : ControllerBase {
+ [HttpGet("/avatars/{_:required}")]
+ [HttpGet("/emojis/{emoji_id:required}.{ext:required}")]
+ [HttpGet("/stickers/{sticker_id:required}.{ext:required}")]
+ [HttpGet("/avatars/{user_id:required}/{avatar_hash:required}.{ext:required}")]
+ [HttpGet("/banners/{user_id:required}/{user_banner:required}.{ext:required}")]
+ public async Task<IActionResult> GetImage(string? ext) {
+ var originalKey = fs.BaseUrl + Request.Path;
+ var cacheKey = Request.Path + Request.QueryString;
+
+ DiscordImageResizeParams resizeParams = new() {
+ Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
+ Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
+ KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
+ Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
+ Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
+ SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au,
+ SpacebarOptimiseGif = Request.Query.ContainsKey("optimiseGif") && bool.TryParse(Request.Query["optimiseGif"], out bool og) && og
+ };
+
+ var entry = await lfc.GetOrAdd(cacheKey, async () => {
+ var original = await fs.GetFile(Request.Path);
+
+ if (Request.Query.Any()) {
+ using var img = await original.ToMagickImageCollectionAsync();
+ dirs.Apply(img, resizeParams);
+
+ var outStream = new MemoryStream();
+ await img.WriteAsync(outStream, img.First().Format);
+ outStream.Position = 0;
+
+ return new LruFileCache.Entry() {
+ Data = outStream.ReadToEnd().ToArray(),
+ MimeType = original.MimeType
+ };
+ }
+
+ return new LruFileCache.Entry() {
+ Data = original.Stream.ReadToEnd().ToArray(),
+ MimeType = original.MimeType
+ };
+ });
+
+ // byte array with mime type result
+ return new FileContentResult(entry.Data, entry.MimeType);
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs b/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs
new file mode 100644
index 00000000..7d4ef087
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs
@@ -0,0 +1,149 @@
+using System.Configuration;
+using System.Net.Http.Headers;
+using ArcaneLibs.Extensions.Streams;
+using ImageMagick;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Build.Globbing;
+using Spacebar.AdminApi.TestClient.Services.Services;
+
+namespace Spacebar.AdminApi.TestClient.Services.Controllers;
+
+[ApiController]
+public class IsPixelArtController(LruFileCache lfc, IFileSource fs, PixelArtDetectionService pads, DiscordImageResizeService dirs) : ControllerBase {
+ private static readonly LruCache<bool> _isPixelArtCache = new(100_000);
+ private static readonly LruFileCache _edgeCache = new(100_000_000);
+
+ [HttpGet("/isPixelArt/{*_:required}")]
+ public async Task<bool> IsPixelArt() {
+ return await _isPixelArtCache.GetOrAddAsync(Request.Path.ToString(), async () => {
+ await using var original = await fs.GetFile(Request.Path.ToString().Replace("/isPixelArt", ""));
+ using var img = await original.ToMagickImageCollectionAsync();
+ return pads.IsPixelArt(img);
+ });
+ }
+
+ [HttpGet("/isCartoonArt/{*_:required}")]
+ public async Task<bool> IsCartoonArt() {
+ return await _isPixelArtCache.GetOrAddAsync(Request.Path.ToString(), async () => {
+ await using var original = await fs.GetFile(Request.Path.ToString().Replace("/isCartoonArt", ""));
+ using var img = await original.ToMagickImageCollectionAsync();
+ return pads.IsCartoonArt(img);
+ });
+ }
+
+ [HttpGet("/edges/{*_:required}")]
+ public async Task<FileContentResult> GetEdges([FromQuery] string applyMode = "pre") {
+ return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
+ var original = await fs.GetFile(Request.Path.ToString().Replace("/edges", ""));
+
+ DiscordImageResizeParams resizeParams = new() {
+ Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
+ Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
+ KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
+ Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
+ Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
+ SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au,
+ SpacebarOptimiseGif = Request.Query.ContainsKey("optimiseGif") && bool.TryParse(Request.Query["optimiseGif"], out bool og) && og
+ };
+
+
+ double radius = 1;
+ if (Request.Query.ContainsKey("radius")) double.TryParse(Request.Query["radius"], out radius);
+
+ var img = await original.ToMagickImageCollectionAsync();
+
+ // if (applyMode == "pre") img = dirs.Apply(img,resizeParams);
+
+ int inFrames = img.Count;
+ using var edged = pads.RenderEdges(img, radius);
+ // if (applyMode == "post") img = dirs.Apply(img,resizeParams);
+
+ Console.WriteLine($"Generated edges for {Request.Path}, radius={radius}, inFrames={inFrames}, outFrames={edged.Count}");
+ return new LruFileCache.Entry {
+ Data = edged.ToByteArray(),
+ // MimeType = Mimes.GetMime(img.First().Format),
+ MimeType = "image/apng"
+ };
+ }).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
+ }
+
+ [HttpGet("/posterize/{*_:required}")]
+ public async Task<FileContentResult> Posterize() {
+ return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
+ var original = await fs.GetFile(Request.Path.ToString().Replace("/posterize", ""));
+ DiscordImageResizeParams resizeParams = new() {
+ Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
+ Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
+ KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
+ Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
+ Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
+ SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au
+ };
+
+
+ double radius = 1;
+ if (Request.Query.ContainsKey("radius")) double.TryParse(Request.Query["radius"], out radius);
+
+ var img = await original.ToMagickImageCollectionAsync();
+
+
+ int inFrames = img.Count;
+ // using var edged = pads.RenderEdges(img, radius);
+ foreach (var frame in img) {
+ // get major color count
+ frame.ColorFuzz = new Percentage(25);
+ frame.Posterize(16, DitherMethod.No);
+ frame.ColorFuzz = new Percentage(0);
+ }
+
+ if(resizeParams.Size.HasValue)
+ img = dirs.Apply(img,resizeParams);
+
+ Console.WriteLine($"Generated edges for {Request.Path}, radius={radius}, inFrames={inFrames}, outFrames={img.Count}");
+ return new LruFileCache.Entry {
+ Data = img.ToByteArray(),
+ // MimeType = Mimes.GetMime(img.First().Format),
+ MimeType = "image/apng"
+ };
+ }).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
+ }
+
+ [HttpGet("/colorFuzz/{*_:required}")]
+ public async Task<FileContentResult> ColorFuzz() {
+ return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
+ var original = await fs.GetFile(Request.Path.ToString().Replace("/colorFuzz", ""));
+ DiscordImageResizeParams resizeParams = new() {
+ Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
+ Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
+ KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
+ Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
+ Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
+ SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au
+ };
+
+
+ double colorFuzz = 1;
+ if (Request.Query.ContainsKey("colorFuzz")) double.TryParse(Request.Query["colorFuzz"], out colorFuzz);
+
+ var img = await original.ToMagickImageCollectionAsync();
+
+
+ int inFrames = img.Count;
+ // using var edged = pads.RenderEdges(img, radius);
+ foreach (var frame in img) {
+ // get major color count
+ frame.ColorFuzz = new Percentage(colorFuzz);
+ }
+
+ if(resizeParams.Size.HasValue)
+ img = dirs.Apply(img,resizeParams);
+
+ Console.WriteLine($"Generated colorFuzz for {Request.Path}, fuzz={colorFuzz}, inFrames={inFrames}, outFrames={img.Count}");
+ return new LruFileCache.Entry {
+ Data = img.ToByteArray(),
+ // MimeType = Mimes.GetMime(img.First().Format),
+ MimeType = "image/apng"
+ };
+ }).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Mimes.cs b/extra/admin-api/Spacebar.Cdn/Mimes.cs
new file mode 100644
index 00000000..508e9a3b
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Mimes.cs
@@ -0,0 +1,20 @@
+using ImageMagick;
+
+namespace Spacebar.AdminApi.TestClient.Services;
+
+public static class Mimes {
+ private static string PrintLogged(string msg, string mime) {
+ Console.WriteLine($"{msg}: {mime}");
+ return mime;
+ }
+
+ public static string GetMime(MagickFormat fmt) => fmt switch {
+ MagickFormat.Png => "image/png",
+ MagickFormat.Jpeg => "image/jpeg",
+ MagickFormat.Gif => "image/gif",
+ MagickFormat.Bmp => "image/bmp",
+ MagickFormat.Tiff => "image/tiff",
+ MagickFormat.WebP => "image/webp",
+ _ => PrintLogged("Unknown mime for format " + fmt.ToString() + "!", "application/octet-stream")
+ };
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Program.cs b/extra/admin-api/Spacebar.Cdn/Program.cs
new file mode 100644
index 00000000..34dc8b13
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Program.cs
@@ -0,0 +1,66 @@
+using ImageMagick;
+using Spacebar.AdminApi.TestClient.Services;
+using Spacebar.AdminApi.TestClient.Services.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+builder.Services.AddSingleton<IFileSource>(new ProxyFileSource("http://cdn.old.server.spacebar.chat"));
+builder.Services.AddSingleton<LruFileCache>(new LruFileCache(1*1024*1024*1024));
+builder.Services.AddSingleton<PixelArtDetectionService>();
+builder.Services.AddSingleton<DiscordImageResizeService>();
+
+builder.Services.AddControllers();
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment()) app.MapOpenApi();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Use((context, next) => {
+ context.Response.Headers["Access-Control-Allow-Origin"] = "*";
+ context.Response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
+ context.Response.Headers["Access-Control-Allow-Headers"] = "*, Authorization";
+ if (context.Request.Method == "OPTIONS") {
+ context.Response.StatusCode = 200;
+ return Task.CompletedTask;
+ }
+
+ return next();
+});
+
+// fallback to proxy in case we dont have a specific endpoint...
+app.MapFallback("{*_}",async context => {
+ var client = new StreamingHttpClient();
+ var requestMessage = new HttpRequestMessage(
+ new HttpMethod(context.Request.Method),
+ "http://cdn.old.server.spacebar.chat" + context.Request.Path + context.Request.QueryString
+ ) {
+ Content = new StreamContent(context.Request.Body)
+ };
+ Console.WriteLine(requestMessage.RequestUri);
+
+ foreach (var header in context.Request.Headers)
+ if (header.Key is not ("Accept-Encoding" or "Host"))
+ requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
+
+ var responseMessage = await client.SendUnhandledAsync(requestMessage, CancellationToken.None);
+ context.Response.StatusCode = (int)responseMessage.StatusCode;
+
+ foreach (var header in responseMessage.Headers) context.Response.Headers[header.Key] = header.Value.ToArray();
+ foreach (var header in responseMessage.Content.Headers) context.Response.Headers[header.Key] = header.Value.ToArray();
+
+ await responseMessage.Content.CopyToAsync(context.Response.Body);
+});
+
+Console.WriteLine("Pre-initializing Magick.NET...");
+MagickNET.Initialize();
+StreamingHttpClient.LogRequests = false;
+
+app.Run();
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json b/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json
new file mode 100644
index 00000000..5205c88e
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5114",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "LD_LIBRARY_PATH": "/home/Rory/git/spacebar/server-master/extra/admin-api/Spacebar.Cdn/result-lib/lib/"
+ }
+ }
+ }
+}
diff --git a/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs b/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs
new file mode 100644
index 00000000..06f5d4f5
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs
@@ -0,0 +1,54 @@
+using System.Runtime.Serialization;
+using ImageMagick;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Spacebar.AdminApi.TestClient.Services.Services;
+
+public class DiscordImageResizeParams {
+ public uint? Size { get; set; }
+ public DiscordImageResizeQuality Quality { get; set; } = DiscordImageResizeQuality.High;
+ public bool KeepAspectRatio { get; set; } = true;
+ public bool Passthrough { get; set; } = true;
+ public bool Animated { get; set; } = true;
+
+ public bool SpacebarAllowUpscale { get; set; } = false;
+ public bool SpacebarOptimiseGif {get;set;} = true;
+}
+
+public enum DiscordImageResizeQuality {
+ [EnumMember(Value = "low")] Low,
+ [EnumMember(Value = "high")] High,
+ [EnumMember(Value = "lossless")] Lossless
+}
+
+public class DiscordImageResizeService(PixelArtDetectionService pads) {
+ public MagickImageCollection Apply(MagickImageCollection img, DiscordImageResizeParams resizeParams) {
+ if (resizeParams.Passthrough) return img;
+ if (img.First().Format == MagickFormat.Gif) {
+ Console.WriteLine("Coalescing gif for resize");
+ img.Coalesce();
+ }
+
+ if (resizeParams.Size.HasValue) {
+ if (resizeParams.Size > 4096)
+ resizeParams.Size = 4096;
+
+ if (img.Max(x => Math.Max(x.Height, x.Width)) > resizeParams.Size || resizeParams.SpacebarAllowUpscale) {
+ Parallel.ForEach(img, new ParallelOptions(){MaxDegreeOfParallelism = 8}, frame => {
+ if (resizeParams.Size.HasValue) {
+ uint oldWidth = frame.Width, oldHeight = frame.Height;
+ frame.Resize(resizeParams.Size.Value, resizeParams.Size.Value, pads.IsPixelArt(frame) ? FilterType.Point : FilterType.Gaussian);
+ Console.WriteLine($"Resized frame from {oldWidth}x{oldHeight} to {frame.Width}x{frame.Height}: {img.IndexOf(frame)}/{img.Count}");
+ }
+ });
+ }
+ }
+
+ if (img.First().Format == MagickFormat.Gif && resizeParams.SpacebarOptimiseGif) {
+ Console.WriteLine("Optimizing gif after resize");
+ img.OptimizePlus();
+ }
+
+ return img;
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs b/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs
new file mode 100644
index 00000000..e1032872
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs
@@ -0,0 +1,50 @@
+using ImageMagick;
+
+namespace Spacebar.AdminApi.TestClient.Services.Services;
+
+public interface IFileSource {
+ public string BaseUrl { get; }
+ public Task<FileInfo> GetFile(string path, CancellationToken? cancellationToken = null);
+}
+
+public class FileInfo : IDisposable, IAsyncDisposable {
+ public string MimeType { get; set; }
+ public Stream Stream { get; set; }
+
+ protected virtual void Dispose(bool disposing) {
+ if (disposing) {
+ Stream.Dispose();
+ }
+ }
+
+ public void Dispose() {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual async ValueTask DisposeAsyncCore() => await Stream.DisposeAsync();
+
+ public async ValueTask DisposeAsync() {
+ await DisposeAsyncCore();
+ GC.SuppressFinalize(this);
+ }
+
+ public async Task<MagickImageCollection> ToMagickImageCollectionAsync() {
+ var ms = new MemoryStream();
+ Stream.Position = 0;
+ await Stream.CopyToAsync(ms);
+ ms.Position = 0;
+ var img = MimeType switch {
+ "image/apng" => new MagickImageCollection(ms, MagickFormat.APng),
+ _ => new MagickImageCollection(ms)
+ };
+
+ // if (img.First().Format == MagickFormat.Png) {
+ // img.Dispose();
+ // ms.Position = 0;
+ // img = new MagickImageCollection(ms, MagickFormat.APng);
+ // }
+
+ return img;
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs b/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs
new file mode 100644
index 00000000..3a25b1e4
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs
@@ -0,0 +1,58 @@
+namespace Spacebar.AdminApi.TestClient.Services.Services;
+
+public class LruFileCache(int maxSizeBytes) {
+ private readonly Dictionary<string, Entry> _entries = new();
+
+ public async Task<Entry?> GetOrAdd(string key, Func<Task<Entry>> factory) {
+ if (_entries.TryGetValue(key, out var entry)) {
+ entry.LastAccessed = DateTimeOffset.UtcNow;
+ return entry;
+ }
+
+ entry = await factory();
+ if (entry.Data.Length > 0)
+ _entries[key] = entry;
+
+ if (_entries.Sum(kv => kv.Value.Data.Length) > maxSizeBytes) {
+ var oldestKey = _entries.OrderBy(kv => kv.Value.LastAccessed).First().Key;
+ _entries.Remove(oldestKey);
+ }
+
+ return entry;
+ }
+
+ public class Entry {
+ public DateTimeOffset LastAccessed { get; set; }
+ public byte[] Data { get; set; }
+ public string MimeType { get; set; }
+ }
+}
+
+public class LruCache<T>(int maxItems) {
+ private readonly Dictionary<string, CacheItem> _items = new();
+
+ public async Task<T?> GetOrAddAsync(string key, Func<Task<T>> factory) {
+ if (_items.TryGetValue(key, out var cacheItem)) {
+ cacheItem.LastAccessed = DateTimeOffset.UtcNow;
+ return cacheItem.Value;
+ }
+
+ var value = await factory();
+ _items[key] = new CacheItem {
+ Value = value,
+ LastAccessed = DateTimeOffset.UtcNow
+ };
+
+ if (_items.Count > maxItems) {
+ var oldestKey = _items.OrderBy(kv => kv.Value.LastAccessed).First().Key;
+ _items.Remove(oldestKey);
+ }
+
+ return value;
+ }
+
+ private class CacheItem {
+ public T Value { get; set; }
+ public DateTimeOffset LastAccessed { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs b/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs
new file mode 100644
index 00000000..204b578e
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs
@@ -0,0 +1,80 @@
+using ImageMagick;
+
+namespace Spacebar.AdminApi.TestClient.Services.Services;
+
+public class PixelArtDetectionService {
+ public bool IsPixelArt<T>(IMagickImage<T> img) where T : struct, IConvertible {
+ // Simple heuristic: if the image has a limited color palette and sharp edges, consider it pixel art
+ var colorCount = img.Histogram().Count;
+ var edgeThreshold = 20; // Arbitrary threshold for edge detection
+
+ using var edgeImg = img.Clone();
+ // noise reduction
+ edgeImg.MedianFilter();
+ edgeImg.Edge(edgeThreshold);
+ var edgePixels = edgeImg.Histogram().Count(kv => kv.Value > 0);
+
+ bool isPixelArt = colorCount < 64 && edgePixels > (img.Width * img.Height) / 10;
+ Console.WriteLine($"IsPixelArt check: colors={colorCount}, edgePixels={edgePixels}, width={img.Width}, height={img.Height} => isPixelArt={isPixelArt}");
+ return isPixelArt;
+ }
+
+ public bool IsCartoonArt<T>(IMagickImage<T> img) where T : struct, IConvertible {
+ // Simple heuristic: if the image has a limited color palette and smooth edges, consider it cartoon art
+ var colorCount = img.Histogram().Count;
+ var edgeThreshold = 5; // Lower threshold for smoother edges
+ using var edgeImg = img.Clone();
+ // noise reduction
+ edgeImg.MedianFilter();
+ edgeImg.Edge(edgeThreshold);
+ var edgePixels = edgeImg.Histogram().Count(kv => kv.Value > 0);
+ bool isCartoonArt = colorCount < 128 && edgePixels < (img.Width * img.Height) / 20;
+ Console.WriteLine($"IsCartoonArt check: colors={colorCount}, edgePixels={edgePixels}, width={img.Width}, height={img.Height} => isCartoonArt={isCartoonArt}");
+ return isCartoonArt;
+ }
+
+ public IMagickImage<T> RenderEdges<T>(IMagickImage<T> img, double radius = 1) where T : struct, IConvertible {
+ var edgeImg = img.Clone();
+ // edgeImg.Edge(radius);
+ edgeImg.WaveletDenoise(new Percentage(50));
+ edgeImg.CannyEdge(radius, sigma: 0.75, lower: new Percentage(10), upper: new Percentage(30));
+ return edgeImg;
+ }
+
+ public bool IsPixelArt(MagickImageCollection img) {
+ Console.WriteLine($"Checking IsPixelArt for image with {img.Count} frames");
+ return img.All(IsPixelArt);
+ }
+
+ public bool IsCartoonArt(MagickImageCollection img) {
+ Console.WriteLine($"Checking IsCartoonArt for image with {img.Count} frames");
+ return img.All(IsCartoonArt);
+ }
+
+ public MagickImageCollection RenderEdges(MagickImageCollection img, double radius = 1) {
+ var edges = img.Clone();
+ if (edges.Count == 1) {
+ edges[0] = RenderEdges(edges[0], radius);
+ // RenderEdges(edges[0], radius);
+ return (MagickImageCollection)edges;
+ }
+
+ Parallel.ForEach(img, frame => {
+ var t = new Thread(() => {
+ var edged = RenderEdges(frame, radius);
+ var idx = img.IndexOf(frame);
+ // while (edges.Count < idx) {
+ // Console.WriteLine($"Waiting to insert edge frame {idx}/{img.Count} (radius={radius}, size={frame.Width}x{frame.Height})");
+ // Thread.Sleep(1000);
+ // }
+ //
+ lock (edges) edges[idx] = edged;
+ //
+ Console.WriteLine($"Edged frame {idx}/{img.Count} (radius={radius}, size={frame.Width}x{frame.Height})");
+ });
+ t.Start();
+ t.Join();
+ });
+ return (MagickImageCollection)edges;
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs b/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs
new file mode 100644
index 00000000..bc6ab2ae
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs
@@ -0,0 +1,29 @@
+namespace Spacebar.AdminApi.TestClient.Services.Services;
+
+public class ProxyFileSource(string baseUrl) : IFileSource {
+ private static LruFileCache _cache = new(100 * 1024 * 1024); // 100 MB
+
+ private readonly StreamingHttpClient _httpClient = new() {
+ BaseAddress = new Uri(baseUrl)
+ };
+
+ public string BaseUrl => baseUrl;
+
+ public async Task<FileInfo> GetFile(string path, CancellationToken? cancellationToken = null) {
+ var res = await _cache.GetOrAdd(path, async () => {
+ var res = await _httpClient.SendUnhandledAsync(new(HttpMethod.Get, path), cancellationToken);
+ res.EnsureSuccessStatusCode();
+ var ms = new MemoryStream();
+ await res.Content.CopyToAsync(ms);
+ return new LruFileCache.Entry {
+ Data = ms.ToArray(),
+ MimeType = res.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"
+ };
+ });
+
+ return new() {
+ Stream = new MemoryStream(res.Data),
+ MimeType = res.MimeType
+ };
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj b/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj
new file mode 100644
index 00000000..3591a69a
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20251207-164820" />
+<!-- <PackageReference Include="Magick.NET-Q16-HDRI-AnyCPU" Version="14.10.1" />-->
+ <PackageReference Include="Magick.NET-Q16-HDRI-OpenMP-x64" Version="14.10.1" />
+ <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Spacebar.Db\Spacebar.Db.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs b/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs
new file mode 100644
index 00000000..8090c053
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs
@@ -0,0 +1,304 @@
+#define SINGLE_HTTPCLIENT // Use a single HttpClient instance for all MatrixHttpClient instances
+// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
+using System.Data;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+
+namespace Spacebar.AdminApi.TestClient.Services;
+
+#if SINGLE_HTTPCLIENT
+// TODO: Add URI wrapper for
+public class StreamingHttpClient {
+ private static readonly HttpClient Client;
+
+ static StreamingHttpClient() {
+ try {
+ var handler = new SocketsHttpHandler {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(15),
+ MaxConnectionsPerServer = 4096,
+ EnableMultipleHttp2Connections = true
+ };
+ Client = new HttpClient(handler) {
+ DefaultRequestVersion = new Version(3, 0),
+ Timeout = TimeSpan.FromDays(1)
+ };
+ }
+ catch (PlatformNotSupportedException e) {
+ Console.WriteLine("Failed to create HttpClient with connection pooling, continuing without connection pool!");
+ Console.WriteLine("Original exception (safe to ignore!):");
+ Console.WriteLine(e);
+
+ Client = new HttpClient {
+ DefaultRequestVersion = new Version(3, 0)
+ };
+ }
+ catch (Exception e) {
+ Console.WriteLine("Failed to create HttpClient:");
+ Console.WriteLine(e);
+ throw;
+ }
+ }
+
+#if SYNC_HTTPCLIENT
+ internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
+#endif
+
+ public static bool LogRequests = true;
+ public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
+
+ public Uri? BaseAddress { get; set; }
+
+ // default headers, not bound to client
+ public HttpRequestHeaders DefaultRequestHeaders { get; set; } =
+ typeof(HttpRequestHeaders).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [], null)?.Invoke([]) as HttpRequestHeaders ??
+ throw new InvalidOperationException("Failed to create HttpRequestHeaders");
+
+ private static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerOptions? options = null) {
+ options ??= new JsonSerializerOptions();
+ // options.Converters.Add(new JsonFloatStringConverter());
+ // options.Converters.Add(new JsonDoubleStringConverter());
+ // options.Converters.Add(new JsonDecimalStringConverter());
+ options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ return options;
+ }
+
+ public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken? cancellationToken) {
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ // if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
+ var swWait = Stopwatch.StartNew();
+#if SYNC_HTTPCLIENT
+ await _rateLimitSemaphore.WaitAsync(cancellationToken);
+#endif
+
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = new Uri(BaseAddress ?? throw new InvalidOperationException("Relative URI passed, but no BaseAddress is specified!"), request.RequestUri);
+ swWait.Stop();
+ var swExec = Stopwatch.StartNew();
+
+ foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
+ foreach (var (key, value) in DefaultRequestHeaders) {
+ if (request.Headers.Contains(key)) continue;
+ request.Headers.Add(key, value);
+ }
+
+ request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
+
+ if (LogRequests)
+ Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: false, hideHeaders: ["Accept"]));
+
+ HttpResponseMessage? responseMessage;
+ try {
+ responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken ?? CancellationToken.None);
+ }
+ catch (Exception e) {
+ if (e is TaskCanceledException or TimeoutException) {
+ if (request.Method == HttpMethod.Get && !(cancellationToken?.IsCancellationRequested ?? false)) {
+ await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken ?? CancellationToken.None);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken ?? CancellationToken.None);
+ }
+ }
+ else if (!e.ToString().StartsWith("TypeError: NetworkError"))
+ Console.WriteLine(
+ $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+
+ throw;
+ }
+#if SYNC_HTTPCLIENT
+ finally {
+ _rateLimitSemaphore.Release();
+ }
+#endif
+
+ // Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
+ if (LogRequests)
+ Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
+ "Server",
+ "Date",
+ "Transfer-Encoding",
+ "Connection",
+ "Vary",
+ "Content-Length",
+ "Access-Control-Allow-Origin",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ "Access-Control-Expose-Headers",
+ "Cache-Control",
+ "Cross-Origin-Resource-Policy",
+ "X-Content-Security-Policy",
+ "Referrer-Policy",
+ "X-Robots-Tag",
+ "Content-Security-Policy"
+ ]));
+
+ return responseMessage;
+ }
+
+ public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) {
+ var responseMessage = await SendUnhandledAsync(request, cancellationToken);
+ if (responseMessage.IsSuccessStatusCode) return responseMessage;
+
+ //retry on gateway timeout
+ // if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
+ // request.ResetSendStatus();
+ // return await SendAsync(request, cancellationToken);
+ // }
+
+ //error handling
+ var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
+ if (content.Length == 0)
+ throw new DataException("Content was empty");
+ // throw new MatrixException() {
+ // ErrorCode = "M_UNKNOWN",
+ // Error = "Unknown error, server returned no content"
+ // };
+
+ // if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
+ if (!content.TrimStart().StartsWith('{')) {
+ responseMessage.EnsureSuccessStatusCode();
+ throw new InvalidDataException("Encountered invalid data:\n" + content);
+ }
+ //we have a matrix error
+
+ throw new Exception("Unknown http exception");
+ // MatrixException? ex;
+ // try {
+ // ex = JsonSerializer.Deserialize<MatrixException>(content);
+ // }
+ // catch (JsonException e) {
+ // throw new LibMatrixException() {
+ // ErrorCode = "M_INVALID_JSON",
+ // Error = e.Message + "\nBody:\n" + await responseMessage.Content.ReadAsStringAsync(cancellationToken)
+ // };
+ // }
+ //
+ // Debug.Assert(ex != null, nameof(ex) + " != null");
+ // ex.RawContent = content;
+ // // Console.WriteLine($"Failed to send request: {ex}");
+ // if (ex.RetryAfterMs is null) throw ex!;
+ // //we have a ratelimit error
+ // await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+
+ // GetAsync
+ public Task<HttpResponseMessage> GetAsync([StringSyntax("Uri")] string? requestUri, CancellationToken? cancellationToken = null) =>
+ SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri), cancellationToken ?? CancellationToken.None);
+
+ // GetFromJsonAsync
+ public async Task<T?> TryGetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) {
+ try {
+ return await GetFromJsonAsync<T>(requestUri, options, cancellationToken);
+ }
+ catch (JsonException e) {
+ Console.WriteLine($"Failed to deserialize response from {requestUri}: {e.Message}");
+ return default;
+ }
+ catch (HttpRequestException e) {
+ Console.WriteLine($"Failed to get {requestUri}: {e.Message}");
+ return default;
+ }
+ }
+
+ public async Task<T> GetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ var response = await SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+
+ return await JsonSerializer.DeserializeAsync<T>(responseStream, options, cancellationToken) ??
+ throw new InvalidOperationException("Failed to deserialize response");
+ }
+
+ // GetStreamAsync
+ public async Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken = default) {
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ var response = await SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsStreamAsync(cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> PutAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) where T : notnull {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
+ Encoding.UTF8, "application/json");
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> PostAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) where T : notnull {
+ options ??= new JsonSerializerOptions();
+ // options.Converters.Add(new JsonFloatStringConverter());
+ // options.Converters.Add(new JsonDoubleStringConverter());
+ // options.Converters.Add(new JsonDecimalStringConverter());
+ options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
+ Encoding.UTF8, "application/json");
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async IAsyncEnumerable<T?> GetAsyncEnumerableFromJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, JsonSerializerOptions? options = null) {
+ options = GetJsonSerializerOptions(options);
+ var res = await GetAsync(requestUri);
+ options.PropertyNameCaseInsensitive = true;
+ var result = JsonSerializer.DeserializeAsyncEnumerable<T>(await res.Content.ReadAsStreamAsync(), options);
+ await foreach (var resp in result) yield return resp;
+ }
+
+ public static async Task<bool> CheckSuccessStatus(string url) {
+ //cors causes failure, try to catch
+ try {
+ var resp = await Client.GetAsync(url);
+ return resp.IsSuccessStatusCode;
+ }
+ catch (Exception e) {
+ Console.WriteLine($"Failed to check success status: {e.Message}");
+ return false;
+ }
+ }
+
+ public async Task<HttpResponseMessage> PostAsync(string uri, HttpContent? content, CancellationToken cancellationToken = default) {
+ var request = new HttpRequestMessage(HttpMethod.Post, uri) {
+ Content = content
+ };
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> DeleteAsync(string url) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ return await SendAsync(request);
+ }
+
+ public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url) {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
+
+ public async Task<HttpResponseMessage> PatchAsJsonAsync<T>(string url, T payload) {
+ var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
+}
+#endif
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/appsettings.Development.json b/extra/admin-api/Spacebar.Cdn/appsettings.Development.json
new file mode 100644
index 00000000..dc0a4c8c
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/appsettings.Development.json
@@ -0,0 +1,17 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Information",
+ "Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware": "Information",
+ "Microsoft.AspNetCore": "Trace", //Warning
+ "Microsoft.AspNetCore.Mvc": "Warning", //Warning
+ "Microsoft.AspNetCore.HostFiltering": "Warning", //Warning
+ "Microsoft.AspNetCore.Cors": "Warning", //Warning
+ "Microsoft.AspNetCore.server.Kestrel": "Information",
+ "Microsoft.AspNetCore.Routing.Matching.DfaMatcher": "Information",
+ // "Microsoft.EntityFrameworkCore": "Warning"
+ "Microsoft.EntityFrameworkCore.Database.Command": "Debug"
+ }
+ }
+}
diff --git a/extra/admin-api/Spacebar.Cdn/appsettings.json b/extra/admin-api/Spacebar.Cdn/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/extra/admin-api/Spacebar.Cdn/gcc-lib-lib b/extra/admin-api/Spacebar.Cdn/gcc-lib-lib
new file mode 120000
index 00000000..84ce47f5
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/gcc-lib-lib
@@ -0,0 +1 @@
+/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/opencl-lib b/extra/admin-api/Spacebar.Cdn/opencl-lib
new file mode 120000
index 00000000..0c99ced3
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/opencl-lib
@@ -0,0 +1 @@
+/nix/store/iayix7sf8n2mcjihl16xxpppv6j2syjm-clr-6.4.3
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Cdn/result-lib b/extra/admin-api/Spacebar.Cdn/result-lib
new file mode 120000
index 00000000..84ce47f5
--- /dev/null
+++ b/extra/admin-api/Spacebar.Cdn/result-lib
@@ -0,0 +1 @@
+/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib
\ No newline at end of file
diff --git a/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor b/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor
new file mode 100644
index 00000000..3a82563f
--- /dev/null
+++ b/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor
@@ -0,0 +1,62 @@
+@page "/StickerCdnTest"
+@using System.Net.Http.Headers
+@using Spacebar.AdminApi.Models
+@using Spacebar.AdminApi.TestClient.Services
+@inject Config Config
+<h3>StickerCdnTest</h3>
+
+<table>
+ @foreach (var sticker in Stickers.OrderBy(x=>x.GuildId).ThenBy(x=>x.Id))
+ {
+ <tr>
+ <td>
+ <img src="@($"{Config.CdnUrl}/stickers/{sticker.Id}.png?optimiseGif=false&applyMode=post")" alt="@sticker.Name" width="128" height="128" />
+ </td>
+ <td>
+ <img src="@($"{Config.CdnUrl}/edges/stickers/{sticker.Id}.png?optimiseGif=false&applyMode=post")" alt="@sticker.Name" width="128" height="128" />
+ </td>
+ <td>
+ PA: @sticker.IsPixelArt
+ <br/>
+ CA: @sticker.IsCartoonArt
+ <br/>
+ @sticker.Name (ID: @sticker.Id)
+ </td>
+ </tr>
+ }
+</table>
+
+@code {
+ private List<Sticker> Stickers { get; set; } = new();
+ protected override async Task OnInitializedAsync()
+ {
+ var hc = new HttpClient();
+ hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Config.AccessToken);
+ var response = await hc.GetAsync(Config.AdminUrl + "/_spacebar/admin/media/sticker/");
+ if (!response.IsSuccessStatusCode) throw new Exception(await response.Content.ReadAsStringAsync());
+ var content = response.Content.ReadFromJsonAsAsyncEnumerable<Sticker>();
+ var tasks = new List<Task>();
+ var ss = new SemaphoreSlim(4, 4);
+ await foreach (var sticker in content) {
+ await ss.WaitAsync();
+ tasks.Add(Task.Run(async () => {
+ var isPixelArtTask = hc.GetFromJsonAsync<bool>($"{Config.CdnUrl}/isPixelArt/stickers/{sticker.Id}.gif");
+ var isCartoonArtTask = hc.GetFromJsonAsync<bool>($"{Config.CdnUrl}/isPixelArt/stickers/{sticker.Id}.gif");
+
+ sticker.IsPixelArt = await isPixelArtTask;
+ sticker.IsCartoonArt = await isCartoonArtTask;
+ Console.WriteLine($"Sticker: {sticker!.Id} - {sticker.Name}, PixelArt: {sticker.IsPixelArt}, CartoonArt: {sticker.IsCartoonArt}");
+ Stickers.Add(sticker!);
+ StateHasChanged();
+ ss.Release();
+ }));
+ }
+ await Task.WhenAll(tasks);
+ StateHasChanged();
+ }
+
+ private class Sticker : StickerModel {
+ public bool IsPixelArt { get; set; }
+ public bool IsCartoonArt { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/flake.lock b/flake.lock
index d4decccc..1805babf 100644
--- a/flake.lock
+++ b/flake.lock
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1767116409,
- "narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=",
+ "lastModified": 1767892417,
+ "narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "cad22e7d996aea55ecab064e84834289143e44a0",
+ "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "github"
},
"original": {
|