Reworked plugin loading into per-plugin hot-reloadable containers (PluginRegistry/PluginSlot/PluginLoadContext/PluginWatcher), so plugins get their own mini service provider composed with the host's. Added a GraphQL API (HotChocolate) for card query/mutation. Added OpenAPI docs via Scalar.

This commit is contained in:
Kayori
2026-08-09 17:54:58 +02:00
parent 7fd68433c1
commit 54c6974cc2
18 changed files with 677 additions and 253 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
+11 -2
View File
@@ -2,6 +2,7 @@ using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace Abstractions;
@@ -16,7 +17,15 @@ public interface IMedusaPlugin
Encoding? ForcedEncoding { get; }
Task OnBuilderInitialize(WebApplicationBuilder builder);
Task OnAppInitialize(WebApplication app);
/// <summary>
/// Register plugin-owned services (DbContext, repositories, etc.) into the plugin's
/// own mini-container. Called for both initial load and hot-reload.
/// Note: also register your DbContext in OnBuilderInitialize if you need migrations via OnAppInitialize.
/// </summary>
void ConfigurePluginServices(IServiceCollection services);
Task OnAppInitialize(WebApplication app, IServiceProvider pluginServices);
Delegate DoesProfileExist { get; }
}
@@ -11,22 +11,16 @@ public static class ApplicationBuilderExtensions
public static IApplicationBuilder UseHandlers(this IApplicationBuilder app)
{
var handlerService = app.ApplicationServices.GetRequiredService<IHandlerService>();
var pluginService = app.ApplicationServices.GetService<IPluginService>();
var pluginAssemblies = pluginService?.GetPlugins().Select(x => x.GetType().Assembly);
var entryAssembly = Assembly.GetEntryAssembly() ?? throw new InvalidOperationException("Could not find entry assembly.");
var assemblies = new List<Assembly> { entryAssembly };
if (pluginAssemblies != null)
{
assemblies.AddRange(pluginAssemblies);
}
foreach (var types in assemblies.Select(assembly => assembly.GetTypes().Where(a => a.GetInterfaces().Contains(typeof(IHandler)) ||
(a.IsSubclassOf(typeof(Handler<,>)) && !a.IsAbstract))))
{
handlerService.Handlers.AddRange(types);
}
// Only register built-in server handlers here.
// Plugin handler types live in PluginSlot.HandlerTypes and are dispatched
// via PluginRegistry in HandlerService using the plugin's own mini-SP.
var builtInHandlers = entryAssembly.GetTypes()
.Where(t => t.GetInterfaces().Contains(typeof(IHandler)) ||
(t.IsSubclassOf(typeof(Handler<,>)) && !t.IsAbstract));
handlerService.Handlers.AddRange(builtInHandlers);
return app;
}
@@ -0,0 +1,63 @@
using System.Reflection;
using HotChocolate.Execution.Configuration;
namespace Server.Extensions;
public static class RequestExecutorBuilderExtensions
{
extension(IRequestExecutorBuilder builder)
{
public IRequestExecutorBuilder AddTypeExtensionsInNamespaceOf(Type type)
{
var extensionNamespace = type.Namespace;
return extensionNamespace is null ?
builder : builder.AddTypeExtensionsInNamespace(extensionNamespace);
}
public IRequestExecutorBuilder AddTypeExtensionsInNamespaceOf<T>()
{
var type = typeof(T);
var extensionNamespace = type.Namespace;
return extensionNamespace is null ?
builder : builder.AddTypeExtensionsInNamespace(extensionNamespace);
}
private IRequestExecutorBuilder AddTypeExtensionsInNamespace(string extensionNamespace)
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var foundAssembly = assemblies.FirstOrDefault(x => x.DefinedTypes.Any(typeInfo => typeInfo.Namespace?.Equals(extensionNamespace) ?? false));
if(foundAssembly is null)
{
try
{
var loaded = Assembly.Load(extensionNamespace.Split('.')[0]);
foundAssembly = loaded;
}
catch(Exception ex)
{
return builder;
}
}
var attributeType = typeof(ExtendObjectTypeAttribute);
var foundTypes = foundAssembly.DefinedTypes
.Where(typeInfo =>
(typeInfo.Namespace?.Equals(extensionNamespace) ?? false) &&
typeInfo.GetCustomAttributes().Any(x => x.GetType() == attributeType))
.ToArray();
foreach(var type in foundTypes)
{
var customAttributes = type.GetCustomAttributes();
if (customAttributes.Any(x => x.GetType() == attributeType))
builder.AddType(type.AsType());
}
return builder;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Security.Claims;
using Abstractions.Entities;
using Abstractions.Services;
using HotChocolate.Authorization;
namespace Server.GraphQL;
[ExtendObjectType(OperationTypeNames.Mutation)]
public class CardMutation
{
[Authorize]
public async Task<IQueryable<Card>> AddCard([Service] ICardService cardService, [Service] AppDbContext appDbContext, string cardNumber, ClaimsPrincipal claimsPrincipal,
CancellationToken cancellationToken)
{
var parsed = int.TryParse(claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "", out var userId);
if (!parsed && userId is 0)
{
throw new ArgumentException("Cannot find userId");
}
var existingCard = await cardService.FindByKonamiId(cardNumber);
if (existingCard is not null)
{
throw new Exception($"Card {cardNumber} is already exists");
}
var cardId = cardService.ConvertKonamiIdToUid(cardNumber);
var card = new Card()
{
KonamiId = cardNumber,
RawId = cardId,
UserId = userId
};
var entity = (await appDbContext.Cards.AddAsync(card, cancellationToken)).Entity;
await appDbContext.SaveChangesAsync(cancellationToken);
return appDbContext.Cards.Where(c => c.Id == entity.Id);
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.Security.Claims;
using Abstractions.Entities;
using HotChocolate.Authorization;
namespace Server.GraphQL;
[ExtendObjectType(OperationTypeNames.Query)]
public class CardQuery
{
[UsePaging(IncludeTotalCount = true), UseFiltering, UseSorting, Authorize]
public IQueryable<Card> GetMyCards([Service] AppDbContext appDbContext, ClaimsPrincipal claimsPrincipal, CancellationToken cancellationToken)
{
var parsed = int.TryParse(claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "", out var userId);
if (!parsed && userId is 0)
{
throw new ArgumentException("Cannot find userId");
}
var cards = appDbContext.Cards.Where(c => c.UserId == userId);
return cards;
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace Server.GraphQL;
public class Mutation
{
}
+6
View File
@@ -0,0 +1,6 @@
namespace Server.GraphQL;
public class Query
{
}
+18
View File
@@ -0,0 +1,18 @@
using System.Reflection;
using System.Runtime.Loader;
using Path = System.IO.Path;
namespace Server.Plugins;
public sealed class PluginLoadContext(string pluginAssemblyPath)
: AssemblyLoadContext(isCollectible: true)
{
private readonly AssemblyDependencyResolver _resolver = new(pluginAssemblyPath);
protected override Assembly? Load(AssemblyName assemblyName)
{
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is not null ? LoadFromAssemblyPath(path) : null;
}
}
+32
View File
@@ -0,0 +1,32 @@
using System.Collections.Immutable;
using Abstractions;
namespace Server.Plugins;
public sealed class PluginRegistry
{
private ImmutableDictionary<PluginSlotKey, PluginSlot> _slots = ImmutableDictionary<PluginSlotKey, PluginSlot>.Empty;
internal void Add(PluginSlot slot)
{
ImmutableInterlocked.AddOrUpdate(ref _slots, slot.Key, slot, (_, _) => slot);
}
internal PluginSlot? Remove(PluginSlotKey key)
{
_slots.TryGetValue(key, out var old);
ImmutableInterlocked.TryRemove(ref _slots, key, out _);
return old;
}
public PluginSlot? GetSlot(PluginSlotKey key)
{
_slots.TryGetValue(key, out var slot);
return slot;
}
public IEnumerable<PluginSlot> GetSlots() => _slots.Values;
public IEnumerable<IMedusaPlugin> GetPlugins() => _slots.Values.Select(s => s.Plugin);
}
public sealed record PluginSlotKey(string GameCode, int? MinVer, int? MaxVer);
@@ -0,0 +1,10 @@
namespace Server.Plugins;
/// <summary>
/// Resolves services from the plugin's own mini-container first,
/// falling back to the host container for shared services (ICardService, IUserService, etc.).
/// </summary>
internal sealed class PluginServiceProvider(IServiceProvider plugin, IServiceProvider host) : IServiceProvider
{
public object? GetService(Type t) => plugin.GetService(t) ?? host.GetService(t);
}
+24
View File
@@ -0,0 +1,24 @@
using Abstractions;
namespace Server.Plugins;
public sealed class PluginSlot(
PluginLoadContext context,
IMedusaPlugin plugin,
IReadOnlyList<Type> handlerTypes,
IServiceProvider pluginServices)
{
public PluginLoadContext Context => context;
public IMedusaPlugin Plugin => plugin;
public IReadOnlyList<Type> HandlerTypes => handlerTypes;
public PluginSlotKey Key => new(plugin.GameCode, plugin.MinVer, plugin.MaxVer);
/// <summary>Mini service provider built from the plugin's ConfigurePluginServices call.</summary>
public IServiceProvider PluginServices => pluginServices;
public void Unload()
{
context.Unload();
if (pluginServices is IDisposable d) d.Dispose();
}
}
+34
View File
@@ -0,0 +1,34 @@
using Server.Services;
using Path = System.IO.Path;
namespace Server.Plugins;
public sealed class PluginWatcher(IPluginService pluginService, ILogger<PluginWatcher> logger)
: IHostedService, IDisposable
{
private FileSystemWatcher? _watcher;
private readonly string _pluginPath = Path.Combine(AppContext.BaseDirectory, "plugins");
public Task StartAsync(CancellationToken cancellationToken)
{
_watcher = new FileSystemWatcher(_pluginPath, "*.dll")
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
};
_watcher.Changed += OnChanged;
_watcher.Created += OnChanged;
_watcher.EnableRaisingEvents = true;
return Task.CompletedTask;
}
private void OnChanged(object _, FileSystemEventArgs e)
{
logger.LogInformation("Plugin file changed: {path}", e.FullPath);
_ = pluginService.ReloadAsync(e.FullPath);
}
public Task StopAsync(CancellationToken ct) { _watcher?.Dispose(); return Task.CompletedTask; }
public void Dispose() => _watcher?.Dispose();
}
+136 -147
View File
@@ -23,6 +23,9 @@ using System.Security.Cryptography;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Scalar.AspNetCore;
using Server.GraphQL;
using Server.Plugins;
var key =
Convert.FromHexString("00000000000069D74627D985EE2187161570D08D93B12455035B6DF0D8205DF5");
@@ -34,19 +37,6 @@ var builder = WebApplication.CreateBuilder(args);
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var logger = loggerFactory.CreateLogger("MedusaLogger");
var pluginService = new PluginService(logger);
// Add services to the container.
pluginService.RegisterPlugins();
var plugins = pluginService.GetPlugins();
foreach (var plugin in plugins)
{
await plugin.OnBuilderInitialize(builder);
}
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<User>()
.AddEntityFrameworkStores<AppDbContext>();
@@ -56,7 +46,9 @@ builder.Services.AddHandlers();
if (!File.Exists("database/Medusa.db"))
{
Directory.CreateDirectory("database");
using(File.Create("database/Medusa.db")) { }
await using (File.Create("database/Medusa.db"))
{
}
}
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite("Data Source=database/Medusa.db;"));
@@ -68,14 +60,49 @@ builder.Services.AddIdentityCore<User>(config =>
config.Lockout.AllowedForNewUsers = true;
}).AddEntityFrameworkStores<AppDbContext>();
builder.Services.AddTransient<ICardService, CardService>();
builder.Services.AddMemoryCache();
var graphQlService = builder.Services.AddGraphQLServer();
graphQlService
.RegisterDbContextFactory<AppDbContext>()
.AddAuthorization()
.AddQueryType<Query>()
.AddTypeExtensionsInNamespaceOf<Query>()
.AddMutationType<Mutation>()
.AddProjections()
.AddFiltering()
.AddSorting()
.UseAutomaticPersistedOperationPipeline()
.AddInMemoryOperationDocumentStorage()
.AddCacheControl()
.ModifyCostOptions(o =>
{
o.MaxFieldCost = 50000;
o.MaxTypeCost = 50000;
});
;
builder.Services.AddOpenApi("v1");
// Create plugin infrastructure manually so DiscoverPluginsAsync can call
// OnBuilderInitialize before the DI container is sealed by builder.Build().
var pluginRegistry = new PluginRegistry();
var pluginService = new PluginService(logger, pluginRegistry);
builder.Services.AddSingleton(pluginRegistry);
builder.Services.AddSingleton<IPluginService>(pluginService);
builder.Services.AddHostedService<PluginWatcher>();
await pluginService.DiscoverPluginsAsync(builder);
builder.Services.AddTransient<ICardService, CardService>();
builder.Services.AddTransient<IUserService, UserService>();
builder.Services.AddSingleton<IXmlLogService, XmlLogService>();
var app = builder.Build();
pluginService.SetServiceProvider(app.Services);
await pluginService.ActivatePluginsAsync(app);
app.Lifetime.ApplicationStarted.Register(() =>
{
@@ -88,7 +115,8 @@ app.Lifetime.ApplicationStarted.Register(() =>
foreach (var address in serverAddresses.Addresses)
{
var uri = new Uri(address);
var displayAddress = Environment.GetEnvironmentVariable("MAIN_ADDRESS") ?? $"{uri.Scheme}://{localIp}:{uri.Port}";
var displayAddress = Environment.GetEnvironmentVariable("MAIN_ADDRESS") ??
$"{uri.Scheme}://{localIp}:{uri.Port}";
Console.WriteLine($"Accessible at: {displayAddress}");
Console.WriteLine($"EAmuse accessible at: {displayAddress}/eamuse");
}
@@ -100,14 +128,16 @@ app.UseStaticFiles();
// Configure the HTTP request pipeline.
//app.UseHttpsRedirection();
if (app.Environment.IsDevelopment())
{
app.MapScalarApiReference(o => o.AddDocument("v1"));
app.MapOpenApi();
}
app.UseMiddleware<BodyParsingMiddleware>();
app.UseHandlers();
foreach (var plugin in plugins)
{
await plugin.OnAppInitialize(app);
}
var apiGroup = app.MapGroup("/api").WithTags("API");
apiGroup.MapCardsApiEndpoints();
apiGroup.MapUserApiEndpoints();
@@ -115,129 +145,39 @@ apiGroup.MapIdentityApi();
var eamuseGroup = app.MapGroup("eamuse");
eamuseGroup.MapPost("/{model}/{module}/{method}", async (string model, string module, string method,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
{
Console.WriteLine(httpContext.Request.Headers.UserAgent);
// Enable buffering to allow multiple reads of the request body
httpContext.Request.EnableBuffering();
var body = "";
// The body is 932 encoded xml
if(httpContext.Request.Body.Length != 0)
{
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
body = await reader.ReadToEndAsync();
}
eamuseGroup.MapPost("/{model}/{module}/{method}", (string model, string module, string method,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
[FromServices] IPluginService pluginService) =>
{
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
httpContext.Request.Body.Position = 0;
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
});
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
eamuseGroup.MapPost("/{m}", (string m, [FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method,
[FromQuery] string? f,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
[FromServices] IPluginService pluginService) =>
{
var amusementRequest = BuildAmusementRequest(model, module, method, f);
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
});
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
eamuseGroup.MapPost("/", ([FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method,
[FromQuery] string? f,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
[FromServices] IPluginService pluginService) =>
{
var amusementRequest = BuildAmusementRequest(model, module, method, f);
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
});
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
var graphqlMap = app.MapGraphQL();
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
app.MapFallbackToFile("/index.html");
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
return TypedResults.Bytes(result, "application/octet-stream");
});
eamuseGroup.MapPost("/{m}", async (string m, [FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method, [FromQuery] string? f,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
{
Console.WriteLine(httpContext.Request.Headers.UserAgent);
// Enable buffering to allow multiple reads of the request body
httpContext.Request.EnableBuffering();
var body = "";
// The body is 932 encoded xml
if(httpContext.Request.Body.Length != 0)
{
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
body = await reader.ReadToEndAsync();
}
httpContext.Request.Body.Position = 0;
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
if(!string.IsNullOrEmpty(f))
{
var fParts = f.Split('.');
amusementRequest.Module = fParts[0];
amusementRequest.Method = fParts[1];
}
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
return TypedResults.Bytes(result, "application/octet-stream");
});
eamuseGroup.MapPost("/", async ([FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method, [FromQuery] string? f,
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
{
Console.WriteLine(httpContext.Request.Headers.UserAgent);
// Enable buffering to allow multiple reads of the request body
httpContext.Request.EnableBuffering();
var body = "";
// The body is 932 encoded xml
if(httpContext.Request.Body.Length != 0)
{
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
body = await reader.ReadToEndAsync();
}
httpContext.Request.Body.Position = 0;
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
if(!string.IsNullOrEmpty(f))
{
var fParts = f.Split('.');
amusementRequest.Module = fParts[0];
amusementRequest.Method = fParts[1];
}
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
return TypedResults.Bytes(result, "application/octet-stream");
});
app.MapFallbackToFile("/index.html");
await using var scope = app.Services.CreateAsyncScope();
@@ -248,13 +188,63 @@ await appDbContext.Database.MigrateAsync();
app.Run();
return;
async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, string info, bool compress, bool encrypt, bool isEncoded, string encoding, ILogger<Program> logger, IHandlerService handlerService, IPluginService pluginService)
AmusementRequest BuildAmusementRequest(string model, string? module, string? method, string? f)
{
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
if (string.IsNullOrEmpty(f)) return amusementRequest;
var fParts = f.Split('.');
amusementRequest.Module = fParts[0];
amusementRequest.Method = fParts[1];
return amusementRequest;
}
async Task<IResult> HandleEAmuseRoute(AmusementRequest amusementRequest, HttpContext httpContext,
ILogger<Program> logger, IHandlerService handlerService, IPluginService pluginService)
{
Console.WriteLine(httpContext.Request.Headers.UserAgent);
// Enable buffering to allow multiple reads of the request body
httpContext.Request.EnableBuffering();
var body = "";
// The body is 932 encoded xml
if (httpContext.Request.Body.Length != 0)
{
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
body = await reader.ReadToEndAsync();
}
httpContext.Request.Body.Position = 0;
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true",
encoding, logger, handlerService, pluginService);
return TypedResults.Bytes(result, "application/octet-stream");
}
async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, string info, bool compress, bool encrypt,
bool isEncoded, string encoding, ILogger<Program> logger, IHandlerService handlerService,
IPluginService pluginService)
{
logger.LogInformation("Handling {Module} {Method}", request.Module, request.Method);
var document = new XDocument();
if(!string.IsNullOrEmpty(body))
if (!string.IsNullOrEmpty(body))
document = XDocument.Parse(body);
var responseXml = await handlerService.Handle(request.Model, request.Module, request.Method, document);
@@ -272,14 +262,14 @@ async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, st
_ => throw new ArgumentException($"Unknown encoding: {encoding}")
};
if(forcedEncoding is not null)
if (forcedEncoding is not null)
{
encodingEnum = forcedEncoding.ToKnownEncoding();
}
byte[] encodedBody;
if(!isEncoded)
if (!isEncoded)
{
var encoder = Encoding.GetEncoding(encoding);
encodedBody = encoder.GetBytes(responseXml.ToString());
@@ -288,21 +278,20 @@ async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, st
{
encodedBody = KbinConverter.Write(responseXml, encodingEnum);
}
if(compress)
if (compress)
{
encodedBody = LZ77.CompressEmpty(encodedBody);
}
var originalInfo = info.Split('-');
if(!encrypt) return encodedBody;
if (!encrypt) return encodedBody;
var part = Convert.FromHexString((originalInfo[1] + originalInfo[2]));
for(var i = 0; i < 6; i++)
for (var i = 0; i < 6; i++)
key[i] = part[i];
var rc4Key = MD5.HashData(key);
encodedBody = RC4.Encrypt(rc4Key, encodedBody);
return encodedBody;
}
}
+24 -12
View File
@@ -7,7 +7,7 @@
<UserSecretsId>43f0ff47-d090-4bfc-a428-9a16f4291fa3</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<SpaRoot>..\Client</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<SpaProxyLaunchCommand>bun run dev</SpaProxyLaunchCommand>
<SpaProxyServerUrl>https://localhost:5173</SpaProxyServerUrl>
</PropertyGroup>
@@ -26,29 +26,41 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Facet" Version="5.4.1" />
<PackageReference Include="Facet.Extensions" Version="5.4.1" />
<PackageReference Include="FastEndpoints" Version="7.2.0" />
<PackageReference Include="FastEndpoints.ClientGen" Version="7.2.0" />
<PackageReference Include="FastEndpoints.Security" Version="7.2.0" />
<PackageReference Include="Facet" Version="6.6.8" />
<PackageReference Include="Facet.Extensions" Version="6.6.8" />
<PackageReference Include="FastEndpoints" Version="8.2.0" />
<PackageReference Include="FastEndpoints.ClientGen" Version="8.2.0" />
<PackageReference Include="FastEndpoints.Security" Version="8.2.0" />
<PackageReference Include="HotChocolate.AspNetCore" Version="16.6.0" />
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="16.6.0" />
<PackageReference Include="HotChocolate.Caching" Version="16.6.0" />
<PackageReference Include="HotChocolate.Caching.Memory" Version="16.6.0" />
<PackageReference Include="HotChocolate.Data" Version="16.6.0" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="16.6.0" />
<PackageReference Include="HotChocolate.PersistedOperations.InMemory" Version="16.6.0" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="16.6.0" />
<PackageReference Include="KbinXml.Net" Version="2.1.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SpaProxy">
<Version>10.*-*</Version>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.7.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.18" />
</ItemGroup>
<ItemGroup>
+22 -6
View File
@@ -5,10 +5,11 @@ using System.Xml.Linq;
using System.Xml.Serialization;
using Abstractions.Handlers;
using Abstractions.Services;
using Server.Plugins;
namespace Server.Services;
public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<HandlerService> logger, IXmlLogService xmlLogService) : IHandlerService
public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<HandlerService> logger, IXmlLogService xmlLogService, PluginRegistry pluginRegistry) : IHandlerService
{
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
private readonly ILogger<HandlerService> _logger = logger;
@@ -17,6 +18,7 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
public async Task<XDocument> Handle(string model, string module, string method, XDocument body)
{
// ── Built-in server handlers (host SP) ──────────────────────────────
foreach(var handler in Handlers)
{
//Module and service are on the attribute
@@ -24,7 +26,8 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
if(handlerAttribute is null)
{
var response = await HandleInheritanceClass(handler, model, module, method, body);
await using var hostScope = _serviceScopeFactory.CreateAsyncScope();
var response = await HandleInheritanceClass(handler, model, module, method, body, hostScope.ServiceProvider);
if(response is not null)
{
@@ -48,20 +51,33 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
: (IHandler)ActivatorUtilities.CreateInstance(scope.ServiceProvider, handler);
var document = new XDocument();//await handlerInstance.HandleAsync(model);
return document;
}
// ── Plugin handlers (composite SP: plugin first → host fallback) ────
foreach (var slot in pluginRegistry.GetSlots())
{
await using var hostScope = _serviceScopeFactory.CreateAsyncScope();
await using var pluginScope = slot.PluginServices.CreateAsyncScope();
var composite = new PluginServiceProvider(pluginScope.ServiceProvider, hostScope.ServiceProvider);
foreach (var handlerType in slot.HandlerTypes)
{
var result = await HandleInheritanceClass(handlerType, model, module, method, body, composite);
if (result is not null) return result;
}
}
//If no handler is found return an empty document
_logger.LogWarning("No handler found for {model}/{module}/{method}", model, module, method);
return new XDocument();
}
private async Task<XDocument?> HandleInheritanceClass(Type handler, string model, string module, string method, XDocument body)
private async Task<XDocument?> HandleInheritanceClass(Type handler, string model, string module, string method, XDocument body, IServiceProvider serviceProvider)
{
var isHandlerWithoutRequest = false;
await using var scope = _serviceScopeFactory.CreateAsyncScope();
var requiredXdocumentConstructor = handler.GetConstructors()
.Any(c => c.GetParameters().Any(p => p.ParameterType == typeof(XDocument)));
@@ -71,7 +87,7 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
throw new InvalidOperationException($"Cannot create instance of abstract class: {handler.FullName}");
}
if(ActivatorUtilities.CreateInstance(scope.ServiceProvider, handler) is not BaseHandler handlerInstance)
if(ActivatorUtilities.CreateInstance(serviceProvider, handler) is not BaseHandler handlerInstance)
{
return null;
}
+13 -3
View File
@@ -1,12 +1,22 @@
using Abstractions;
using Microsoft.AspNetCore.Builder;
namespace Server.Services;
public interface IPluginService
{
void AddPlugin(IMedusaPlugin plugin);
void RegisterPlugins();
List<IMedusaPlugin> GetPlugins();
/// <summary>Pre-build: scan plugin dirs, load slots, call OnBuilderInitialize on each.</summary>
Task DiscoverPluginsAsync(WebApplicationBuilder builder);
/// <summary>Post-build: add slots to the registry, call OnAppInitialize on each.</summary>
Task ActivatePluginsAsync(WebApplication app);
/// <summary>Hot-swap a single plugin DLL without restarting.</summary>
Task ReloadAsync(string pluginDllPath);
void SetServiceProvider(IServiceProvider serviceProvider);
IEnumerable<IMedusaPlugin> GetPlugins();
IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null);
Task<bool> DoesProfileExistAsync(IMedusaPlugin plugin, string cardId);
}
+202 -68
View File
@@ -1,110 +1,244 @@
using System.Diagnostics;
using System.Reflection;
using Abstractions;
using Abstractions.Handlers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Server.Plugins;
using Path = System.IO.Path;
namespace Server.Services;
public class PluginService(ILogger logger) : IPluginService
public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPluginService
{
private List<IMedusaPlugin> Plugins { get; } = [];
private readonly string _pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins") ;
private readonly string _pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins");
private readonly List<PluginSlot> _pendingSlots = [];
private IServiceProvider? _serviceProvider;
private WebApplication? _webApplication;
public void SetServiceProvider(IServiceProvider serviceProvider)
{
public void SetServiceProvider(IServiceProvider serviceProvider) =>
_serviceProvider = serviceProvider;
}
public void AddPlugin(IMedusaPlugin plugin)
{
Plugins.Add(plugin);
}
// ── Phase 1: before builder.Build() ─────────────────────────────────────
public void RegisterPlugins()
public async Task DiscoverPluginsAsync(WebApplicationBuilder builder)
{
var watch = new Stopwatch();
watch.Start();
logger.LogInformation("Registering plugins");
var watch = Stopwatch.StartNew();
logger.LogInformation("Discovering plugins in {path}", _pluginPath);
if (!Directory.Exists(_pluginPath))
Directory.CreateDirectory(_pluginPath);
var dlls = Directory.EnumerateFiles(_pluginPath, "*.dll").ToArray();
for (var i = 0; i < dlls.Length; i++)
var dirs = Directory.EnumerateDirectories(_pluginPath).ToArray();
if (dirs.Length == 0)
{
var assembly = Assembly.LoadFrom(dlls[i]);
Type? medusaPlugin = null;
try
{
medusaPlugin = assembly.GetTypes().FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin)));
}
catch(ReflectionTypeLoadException ex)
{
logger.LogWarning("Plugin '{}' appears to be outdated or incompatible. Please update it to the latest version of Medusa. Details: {}",
assembly.GetName().Name,
string.Join("; ", ex.LoaderExceptions.Select(e => e?.Message ?? "Unknown error")));
continue;
}
if (medusaPlugin is null)
{
logger.LogError("Could not find medusa plugin in dll {}", assembly.GetName().Name);
continue;
}
logger.LogInformation("Trying to register plugin {}", assembly.GetName().Name);
if (Activator.CreateInstance(medusaPlugin) is not IMedusaPlugin instance)
{
logger.LogError("Could not create instance of plugin {}", medusaPlugin.Name);
continue;
}
logger.LogInformation("Registering plugin {}", instance.Name);
AddPlugin(instance);
logger.LogInformation("Registered plugin {currentCount} of {fullCount}", i + 1, dlls.Length);
logger.LogInformation("No plugin directories found");
return;
}
var loaded = 0;
for (var i = 0; i < dirs.Length; i++)
{
var dll = FindPluginDll(dirs[i]);
if (dll is null)
{
logger.LogWarning("Skipping directory '{dir}': no DLL found", Path.GetFileName(dirs[i]));
continue;
}
var slot = TryLoadSlot(dll);
if (slot is null) continue; // TryLoadSlot already logged the reason
logger.LogInformation("Registering plugin {} (gameCode={gameCode}, {verRange})",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer));
await slot.Plugin.OnBuilderInitialize(builder);
_pendingSlots.Add(slot);
loaded++;
logger.LogInformation("Registered plugin {currentCount} of {fullCount}", loaded, dirs.Length);
}
watch.Stop();
logger.LogInformation("Registered {registeredPluginsCount} plugin(s) in {elapsed} ms", dlls.Length, watch.ElapsedMilliseconds);
}
public List<IMedusaPlugin> GetPlugins()
{
return Plugins;
logger.LogInformation("Registered {registeredPluginsCount} plugin(s) in {elapsed} ms",
loaded, watch.ElapsedMilliseconds);
}
public IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null)
// ── Phase 2: after builder.Build() ──────────────────────────────────────
public async Task ActivatePluginsAsync(WebApplication app)
{
var foundPlugins = Plugins.Where(x => x.GameCode == gameCode);
if (minVer != null)
foundPlugins = foundPlugins.Where(x => x.MinVer <= minVer);
if (maxVer != null)
foundPlugins = foundPlugins.Where(x => x.MaxVer >= maxVer);
return foundPlugins.FirstOrDefault();
_webApplication = app;
foreach (var slot in _pendingSlots)
{
pluginRegistry.Add(slot);
await slot.Plugin.OnAppInitialize(app, slot.PluginServices);
logger.LogInformation("Plugin '{name}' activated (gameCode={gameCode}, {verRange})",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer));
}
_pendingSlots.Clear();
}
// ── Hot-reload ───────────────────────────────────────────────────────────
public async Task ReloadAsync(string pluginDllPath)
{
var name = Path.GetFileNameWithoutExtension(pluginDllPath);
logger.LogInformation("Hot-reloading plugin from '{dll}'", name);
var newSlot = TryLoadSlot(pluginDllPath);
if (newSlot is null) return; // TryLoadSlot already logged the reason
var old = pluginRegistry.Remove(newSlot.Key);
if (old is not null)
logger.LogInformation("Plugin '{name}' (gameCode={gameCode}, {verRange}) unloaded",
old.Plugin.Name, old.Plugin.GameCode, VerRange(old.Plugin.MinVer, old.Plugin.MaxVer));
pluginRegistry.Add(newSlot);
if (_webApplication is not null)
await newSlot.Plugin.OnAppInitialize(_webApplication, newSlot.PluginServices);
old?.Unload();
logger.LogInformation("Plugin '{name}' v{version} hot-reloaded successfully (gameCode={gameCode}, {verRange})",
newSlot.Plugin.Name, newSlot.Plugin.Version,
newSlot.Plugin.GameCode, VerRange(newSlot.Plugin.MinVer, newSlot.Plugin.MaxVer));
}
// ── Queries ──────────────────────────────────────────────────────────────
public IEnumerable<IMedusaPlugin> GetPlugins() =>
pluginRegistry.GetPlugins();
public IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null) =>
pluginRegistry.GetPlugins()
.Where(p => p.GameCode == gameCode)
.Where(p => minVer is null || p.MinVer <= minVer)
.Where(p => maxVer is null || p.MaxVer >= maxVer)
.FirstOrDefault();
public async Task<bool> DoesProfileExistAsync(IMedusaPlugin plugin, string cardId)
{
if (_serviceProvider is null)
throw new InvalidOperationException("PluginService has not been initialized with a service provider yet.");
var slot = pluginRegistry.GetSlots()
.FirstOrDefault(s => s.Plugin.GameCode == plugin.GameCode
&& s.Plugin.MinVer == plugin.MinVer
&& s.Plugin.MaxVer == plugin.MaxVer);
var method = plugin.DoesProfileExist;
var parameters = method.Method.GetParameters();
using var scope = _serviceProvider.CreateScope();
await using var hostScope = _serviceProvider.CreateAsyncScope();
IServiceProvider composite;
if (slot is not null)
{
await using var pluginScope = slot.PluginServices.CreateAsyncScope();
composite = new PluginServiceProvider(pluginScope.ServiceProvider, hostScope.ServiceProvider);
}
else
{
composite = hostScope.ServiceProvider;
}
var args = parameters.Select(p =>
{
if (p.Name == "cardId") return (object)cardId;
var isFromServices = p.GetCustomAttribute<FromServicesAttribute>() != null;
if (isFromServices) return scope.ServiceProvider.GetRequiredService(p.ParameterType);
throw new InvalidOperationException($"Don't know how to resolve parameter '{p.Name}' on plugin delegate DoesProfileExist");
return isFromServices
? composite.GetRequiredService(p.ParameterType)
: throw new InvalidOperationException(
$"Don't know how to resolve parameter '{p.Name}' on plugin delegate DoesProfileExist");
}).ToArray();
return await (Task<bool>)method.DynamicInvoke(args)!;
}
}
// ── Internal ─────────────────────────────────────────────────────────────
private PluginSlot? TryLoadSlot(string dllPath)
{
var context = new PluginLoadContext(dllPath);
Assembly assembly;
try
{
assembly = context.LoadFromAssemblyPath(dllPath);
}
catch (Exception ex)
{
context.Unload();
logger.LogError("Failed to load assembly '{}': {}", Path.GetFileName(dllPath), ex.Message);
return null;
}
Type? pluginType;
IReadOnlyList<Type> handlerTypes;
try
{
pluginType = assembly.GetTypes()
.FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin)));
handlerTypes = assembly.GetTypes()
.Where(t => typeof(BaseHandler).IsAssignableFrom(t)
&& t is { IsAbstract: false, IsInterface: false })
.ToList();
}
catch (ReflectionTypeLoadException ex)
{
context.Unload();
logger.LogWarning("Plugin '{}' appears to be outdated or incompatible. Please update it to the latest version of Medusa. Details: {}",
assembly.GetName().Name,
string.Join("; ", ex.LoaderExceptions.Select(e => e?.Message ?? "Unknown error")));
return null;
}
if (pluginType is null)
{
context.Unload();
logger.LogError("Could not find medusa plugin in dll {}", assembly.GetName().Name);
return null;
}
logger.LogInformation("Trying to register plugin {}", assembly.GetName().Name);
if (Activator.CreateInstance(pluginType) is not IMedusaPlugin instance)
{
context.Unload();
logger.LogError("Could not create instance of plugin {}", pluginType.Name);
return null;
}
var sc = new ServiceCollection();
instance.ConfigurePluginServices(sc);
var pluginSp = sc.BuildServiceProvider();
return new PluginSlot(context, instance, handlerTypes, pluginSp);
}
// Datecodes are YYYYMMDDXX, e.g. 2025070800 → "2025-07-08 r00"
private static string FormatVer(int ver)
{
var s = ver.ToString("D10");
return $"{s[..4]}-{s[4..6]}-{s[6..8]} r{s[8..]}";
}
private static string VerRange(int? minVer, int? maxVer) =>
(minVer, maxVer) switch
{
(null, null) => "all versions",
(not null, null) => $"{FormatVer(minVer.Value)}+",
(null, not null) => $"up to {FormatVer(maxVer.Value)}",
_ => $"{FormatVer(minVer.Value)} {FormatVer(maxVer.Value)}"
};
private static string? FindPluginDll(string dir) =>
Directory.EnumerateFiles(dir, "*.dll")
.FirstOrDefault(f => !Path.GetFileNameWithoutExtension(f)
.Equals("Abstractions", StringComparison.OrdinalIgnoreCase));
}