From 24f394116a60367e1f414c29248de6e0998c78bc Mon Sep 17 00:00:00 2001 From: Kayori Date: Sun, 6 Sep 2026 15:13:10 +0200 Subject: [PATCH] Add sppass/cardless login support - SppassOpenHandler/LookupHandler/CloseHandler implementing the eAmuse sppass.open/lookup/close xrpc methods (request/response schema confirmed by tracing the client's psmap-based response parser and by live testing against a real cabinet build) - SppassSessionService backs open/lookup/close with a token-keyed session (url + interval for the cabinet to display/poll, later card_type/card_id once approved) - CardlessApi: POST /api/cardless/{token}/approve, letting a signed-in player's phone approve the pending token using their account's first registered card - Cardless frontend pages ([token].vue, scan.vue) and useCardlessAuth composable driving the approval call - GetConfig GraphQL query + ConfigQuery resolver so the cardless page can show which server it's talking to - Wire ISppassSessionService and the cardless routes into Program.cs; HandlerService now parses model into a GameModel once per request instead of re-parsing per handler --- .../Client/src/composables/useCardlessAuth.ts | 31 +++++ Core/Client/src/data/customFetch.ts | 67 +++++++++++ .../src/graphql/queries/getConfig.graphql | 3 + Core/Client/src/pages/cardless/[token].vue | 51 +++++++++ Core/Client/src/pages/cardless/scan.vue | 108 ++++++++++++++++++ .../src/types/api/approveSessionResponse.ts | 11 ++ Core/Server/Api/CardlessApi.cs | 67 +++++++++++ Core/Server/GraphQL/ConfigQuery.cs | 12 ++ .../Handlers/Sppass/SppassCloseHandler.cs | 23 ++++ .../Handlers/Sppass/SppassLookupHandler.cs | 49 ++++++++ .../Handlers/Sppass/SppassOpenHandler.cs | 37 ++++++ .../Models/Request/SppassCloseRequest.cs | 10 ++ .../Models/Request/SppassLookupRequest.cs | 10 ++ .../Models/Response/ApproveSessionResponse.cs | 7 ++ .../Models/Response/SppassCloseResponse.cs | 10 ++ .../Models/Response/SppassLookupResponse.cs | 22 ++++ .../Models/Response/SppassOpenResponse.cs | 22 ++++ Core/Server/Program.cs | 31 ++++- Core/Server/Services/HandlerService.cs | 25 ++-- Core/Server/Services/ISppassSessionService.cs | 9 ++ Core/Server/Services/SppassSessionService.cs | 48 ++++++++ Core/Server/Utils/ServerAddress.cs | 7 ++ 22 files changed, 642 insertions(+), 18 deletions(-) create mode 100644 Core/Client/src/composables/useCardlessAuth.ts create mode 100644 Core/Client/src/data/customFetch.ts create mode 100644 Core/Client/src/graphql/queries/getConfig.graphql create mode 100644 Core/Client/src/pages/cardless/[token].vue create mode 100644 Core/Client/src/pages/cardless/scan.vue create mode 100644 Core/Client/src/types/api/approveSessionResponse.ts create mode 100644 Core/Server/Api/CardlessApi.cs create mode 100644 Core/Server/GraphQL/ConfigQuery.cs create mode 100644 Core/Server/Handlers/Sppass/SppassCloseHandler.cs create mode 100644 Core/Server/Handlers/Sppass/SppassLookupHandler.cs create mode 100644 Core/Server/Handlers/Sppass/SppassOpenHandler.cs create mode 100644 Core/Server/Models/Request/SppassCloseRequest.cs create mode 100644 Core/Server/Models/Request/SppassLookupRequest.cs create mode 100644 Core/Server/Models/Response/ApproveSessionResponse.cs create mode 100644 Core/Server/Models/Response/SppassCloseResponse.cs create mode 100644 Core/Server/Models/Response/SppassLookupResponse.cs create mode 100644 Core/Server/Models/Response/SppassOpenResponse.cs create mode 100644 Core/Server/Services/ISppassSessionService.cs create mode 100644 Core/Server/Services/SppassSessionService.cs create mode 100644 Core/Server/Utils/ServerAddress.cs diff --git a/Core/Client/src/composables/useCardlessAuth.ts b/Core/Client/src/composables/useCardlessAuth.ts new file mode 100644 index 0000000..f3c0a20 --- /dev/null +++ b/Core/Client/src/composables/useCardlessAuth.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@tanstack/vue-query'; +import { postApiCardlessTokenApprove } from '@/data/apiClient'; +import type { ApproveSessionResponse } from '@/types/api'; + +export type ApproveSessionResult = ApproveSessionResponse; + +const approveSessionRequest = async (token: string): Promise => { + const result = await postApiCardlessTokenApprove(token, { + headers: { Authorization: `Bearer ${sessionStorage.getItem('accessToken')}` }, + }); + + if (result.data && typeof result.data.success === 'boolean') { + return result.data; + } + + return { + success: false, + message: result.status === 200 ? 'Unexpected response from server.' : 'Something went wrong approving this login.', + }; +}; + +export const useCardlessAuth = () => { + const { mutateAsync: approveSession, isPending: approving } = useMutation({ + mutationFn: approveSessionRequest, + }); + + return { + approveSession, + approving, + }; +}; diff --git a/Core/Client/src/data/customFetch.ts b/Core/Client/src/data/customFetch.ts new file mode 100644 index 0000000..07da2e6 --- /dev/null +++ b/Core/Client/src/data/customFetch.ts @@ -0,0 +1,67 @@ +import { useUserStore } from '@/store/userStore'; +import type { TokenResponse } from '@/types/tokenResponse'; + +let refreshPromise: Promise | null = null; + +const refreshAccessToken = (): Promise => { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + return Promise.resolve(false); + } + + refreshPromise ??= (async () => { + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + + if (!res.ok) { + return false; + } + + const tokens = (await res.json()) as TokenResponse; + sessionStorage.setItem('accessToken', tokens.accessToken); + localStorage.setItem('refreshToken', tokens.refreshToken); + + const store = useUserStore(); + store.setAccessToken(tokens.accessToken); + store.setRefreshToken(tokens.refreshToken); + + return true; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +const toResult = async (res: Response): Promise => { + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + const data = body ? JSON.parse(body) : undefined; + return { data, status: res.status, headers: res.headers } as T; +}; + +const withAuthHeader = (options: RequestInit): RequestInit => ({ + ...options, + headers: { ...options.headers, Authorization: `Bearer ${sessionStorage.getItem('accessToken')}` }, +}); + +export const customFetch = async (url: string, options: RequestInit): Promise => { + const res = await fetch(url, options); + + if (res.status !== 401) { + return toResult(res); + } + + if (!(await refreshAccessToken())) { + return toResult(res); + } + + const retryRes = await fetch(url, withAuthHeader(options)); + return toResult(retryRes); +}; diff --git a/Core/Client/src/graphql/queries/getConfig.graphql b/Core/Client/src/graphql/queries/getConfig.graphql new file mode 100644 index 0000000..e97b9ab --- /dev/null +++ b/Core/Client/src/graphql/queries/getConfig.graphql @@ -0,0 +1,3 @@ +query GetConfig { + publicUrl +} diff --git a/Core/Client/src/pages/cardless/[token].vue b/Core/Client/src/pages/cardless/[token].vue new file mode 100644 index 0000000..c001e0e --- /dev/null +++ b/Core/Client/src/pages/cardless/[token].vue @@ -0,0 +1,51 @@ + + + diff --git a/Core/Client/src/pages/cardless/scan.vue b/Core/Client/src/pages/cardless/scan.vue new file mode 100644 index 0000000..2ccd55c --- /dev/null +++ b/Core/Client/src/pages/cardless/scan.vue @@ -0,0 +1,108 @@ + + + diff --git a/Core/Client/src/types/api/approveSessionResponse.ts b/Core/Client/src/types/api/approveSessionResponse.ts new file mode 100644 index 0000000..1d3167e --- /dev/null +++ b/Core/Client/src/types/api/approveSessionResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Server | v1 + * OpenAPI spec version: 1.0.0 + */ + +export interface ApproveSessionResponse { + success: boolean; + message: string; +} diff --git a/Core/Server/Api/CardlessApi.cs b/Core/Server/Api/CardlessApi.cs new file mode 100644 index 0000000..8b66eb0 --- /dev/null +++ b/Core/Server/Api/CardlessApi.cs @@ -0,0 +1,67 @@ +using Abstractions.Services; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Server.Models.Response; +using Server.Services; + +namespace Server.Api; + +public static class CardlessApi +{ + public static RouteGroupBuilder MapCardlessApiEndpoints(this RouteGroupBuilder group) + { + var cardlessGroup = group.MapGroup("/cardless").WithTags("Cardless"); + cardlessGroup.MapPost("/{token}/approve", ApproveSession).RequireAuthorization(); + return group; + } + + // The phone side of cardless login never picks a card - it approves + // whichever token the cabinet's QR encodes using the signed-in player's + // first registered card. sppass.lookup's response only ever carries a + // single card_type/card_id pair per token - there's no field anywhere in + // the protocol for the cabinet to receive (or the phone to submit) a + // choice among several cards, so this is a deliberate simplification + private static async Task, UnauthorizedHttpResult, BadRequest, NotFound>> ApproveSession( + string token, HttpContext httpContext, + [FromServices] ICardService cardService, [FromServices] ISppassSessionService sessionService) + { + var user = httpContext.User; + if (!(user.Identity?.IsAuthenticated ?? false)) + { + return TypedResults.Unauthorized(); + } + + var userIdClaim = user.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + if (string.IsNullOrEmpty(userIdClaim)) + { + return TypedResults.Unauthorized(); + } + + var cards = await cardService.GetCardsByUserIdAsync(long.Parse(userIdClaim)); + var card = cards.FirstOrDefault(); + + if (card is null) + { + return TypedResults.BadRequest(new ApproveSessionResponse { Success = false, Message = "No card registered on this account yet." }); + } + + // `cardType` has no value confirmed directly from sppass itself - + // decompiling SOUND VOLTEX's sppass module only shows the client + // requiring it be non-empty and parse as base-10, never comparing it + // against a real value. "4" is borrowed from KAMUNITY.AvsTypes.CardType + // (MCARD=0, ICCARD=1, FELICA=2, VIRTUAL=4), KONAMI's shared platform + // card-type enum used identically by two other arcade titles' decompiled + // C# (Polaris Chord, Chase Chase Jokers) - VIRTUAL is the platform's own + // name for "no physical card," an exact semantic match for cardless + // login. Not proven that sppass reuses this enum, so if the cabinet + // rejects it, that's the first assumption to revisit. + var approved = sessionService.TryApprove(token, "4", card.RawId); + + if (!approved) + { + return TypedResults.NotFound(new ApproveSessionResponse { Success = false, Message = "This QR code has expired - go back to the cabinet and try again." }); + } + + return TypedResults.Ok(new ApproveSessionResponse { Success = true, Message = $"Approved with card {card.KonamiId}." }); + } +} diff --git a/Core/Server/GraphQL/ConfigQuery.cs b/Core/Server/GraphQL/ConfigQuery.cs new file mode 100644 index 0000000..463aaee --- /dev/null +++ b/Core/Server/GraphQL/ConfigQuery.cs @@ -0,0 +1,12 @@ +using Server.Utils; + +namespace Server.GraphQL; + +// Lets the frontend read server-side config values instead of hardcoding +// them - starting with the public base URL sppass.open already builds QR +// links from (see ServerAddress + Handlers/Sppass/SppassOpenHandler). +[ExtendObjectType(OperationTypeNames.Query)] +public class ConfigQuery +{ + public string PublicUrl() => ServerAddress.PublicUrl; +} diff --git a/Core/Server/Handlers/Sppass/SppassCloseHandler.cs b/Core/Server/Handlers/Sppass/SppassCloseHandler.cs new file mode 100644 index 0000000..4e2eb50 --- /dev/null +++ b/Core/Server/Handlers/Sppass/SppassCloseHandler.cs @@ -0,0 +1,23 @@ +using Abstractions.Handlers; +using Server.Models.Request; +using Server.Models.Response; +using Server.Services; + +namespace Server.Handlers.Sppass; + +public class SppassCloseHandler(ISppassSessionService sessionService) : Handler +{ + private readonly ISppassSessionService _sessionService = sessionService; + + public override void Configure() + { + Module("sppass"); + Method("close"); + } + + public override SppassCloseResponse Handle(SppassCloseRequest req, GameModel model) + { + _sessionService.Close(req.Token); + return new SppassCloseResponse { Status = 0 }; + } +} diff --git a/Core/Server/Handlers/Sppass/SppassLookupHandler.cs b/Core/Server/Handlers/Sppass/SppassLookupHandler.cs new file mode 100644 index 0000000..a6e0b5d --- /dev/null +++ b/Core/Server/Handlers/Sppass/SppassLookupHandler.cs @@ -0,0 +1,49 @@ +using Abstractions.Handlers; +using Server.Models.Request; +using Server.Models.Response; +using Server.Services; + +namespace Server.Handlers.Sppass; + +public class SppassLookupHandler(ISppassSessionService sessionService) : Handler +{ + private readonly ISppassSessionService _sessionService = sessionService; + + public override void Configure() + { + Module("sppass"); + Method("lookup"); + } + + public override SppassLookupResponse Handle(SppassLookupRequest req, GameModel model) + { + var session = _sessionService.GetSession(req.Token); + + // Unknown/expired token gets the same "no card yet" shape as a + // genuinely-still-waiting session rather than an error: the cabinet + // only ever polls a token it just received from open, so this only + // happens if the session timed out mid-poll, and the cabinet already + // handles that itself via its own expiry (expire_datetime, reported + // on open). + if (session is null || !session.IsApproved) + { + return new SppassLookupResponse + { + Status = 0, + Url = SppassOpenHandler.BuildCardlessUrl(req.Token), + Interval = 3, + CardType = "", + CardId = "" + }; + } + + return new SppassLookupResponse + { + Status = 0, + Url = SppassOpenHandler.BuildCardlessUrl(req.Token), + Interval = 3, + CardType = session.CardType!, + CardId = session.CardId! + }; + } +} diff --git a/Core/Server/Handlers/Sppass/SppassOpenHandler.cs b/Core/Server/Handlers/Sppass/SppassOpenHandler.cs new file mode 100644 index 0000000..ae20f0f --- /dev/null +++ b/Core/Server/Handlers/Sppass/SppassOpenHandler.cs @@ -0,0 +1,37 @@ +using Abstractions.Handlers; +using Server.Models.Response; +using Server.Services; +using Server.Utils; + +namespace Server.Handlers.Sppass; + +public class SppassOpenHandler(ISppassSessionService sessionService) : HandlerWithoutRequest +{ + private readonly ISppassSessionService _sessionService = sessionService; + + public override void Configure() + { + Module("sppass"); + Method("open"); + } + + public override SppassOpenResponse Handle(GameModel model) + { + var session = _sessionService.Open(); + + return new SppassOpenResponse + { + Status = 0, + Token = session.Token, + ExpireDatetime = session.ExpiresAt.ToString("yyyy-MM-dd HH:mm:ss"), + Url = BuildCardlessUrl(session.Token), + // Seconds - the client turns this into ms itself, confirmed from + // the decompiled receiver: ms = max(1000, seconds*1000), and it + // defaults to 10s if this is omitted/zero. + Interval = 3 + }; + } + + internal static string BuildCardlessUrl(string token) + => $"{ServerAddress.PublicUrl.TrimEnd('/')}/cardless/{token}"; +} diff --git a/Core/Server/Models/Request/SppassCloseRequest.cs b/Core/Server/Models/Request/SppassCloseRequest.cs new file mode 100644 index 0000000..f674f26 --- /dev/null +++ b/Core/Server/Models/Request/SppassCloseRequest.cs @@ -0,0 +1,10 @@ +using System.Xml.Serialization; + +namespace Server.Models.Request; + +[XmlRoot("sppass")] +public class SppassCloseRequest +{ + [XmlAttribute("token")] + public required string Token { get; set; } +} diff --git a/Core/Server/Models/Request/SppassLookupRequest.cs b/Core/Server/Models/Request/SppassLookupRequest.cs new file mode 100644 index 0000000..44dcdc1 --- /dev/null +++ b/Core/Server/Models/Request/SppassLookupRequest.cs @@ -0,0 +1,10 @@ +using System.Xml.Serialization; + +namespace Server.Models.Request; + +[XmlRoot("sppass")] +public class SppassLookupRequest +{ + [XmlAttribute("token")] + public required string Token { get; set; } +} diff --git a/Core/Server/Models/Response/ApproveSessionResponse.cs b/Core/Server/Models/Response/ApproveSessionResponse.cs new file mode 100644 index 0000000..26fe431 --- /dev/null +++ b/Core/Server/Models/Response/ApproveSessionResponse.cs @@ -0,0 +1,7 @@ +namespace Server.Models.Response; + +public class ApproveSessionResponse +{ + public required bool Success { get; set; } + public required string Message { get; set; } +} diff --git a/Core/Server/Models/Response/SppassCloseResponse.cs b/Core/Server/Models/Response/SppassCloseResponse.cs new file mode 100644 index 0000000..068bb84 --- /dev/null +++ b/Core/Server/Models/Response/SppassCloseResponse.cs @@ -0,0 +1,10 @@ +using System.Xml.Serialization; + +namespace Server.Models.Response; + +[XmlRoot("sppass")] +public class SppassCloseResponse +{ + [XmlAttribute("status")] + public required int Status { get; set; } +} diff --git a/Core/Server/Models/Response/SppassLookupResponse.cs b/Core/Server/Models/Response/SppassLookupResponse.cs new file mode 100644 index 0000000..b43c6b2 --- /dev/null +++ b/Core/Server/Models/Response/SppassLookupResponse.cs @@ -0,0 +1,22 @@ +using System.Xml.Serialization; + +namespace Server.Models.Response; + +[XmlRoot("sppass")] +public class SppassLookupResponse +{ + [XmlAttribute("status")] + public required int Status { get; set; } + + [XmlAttribute("url")] + public required string Url { get; set; } + + [XmlAttribute("interval")] + public required int Interval { get; set; } + + [XmlAttribute("card_type")] + public string CardType { get; set; } = ""; + + [XmlAttribute("card_id")] + public string CardId { get; set; } = ""; +} diff --git a/Core/Server/Models/Response/SppassOpenResponse.cs b/Core/Server/Models/Response/SppassOpenResponse.cs new file mode 100644 index 0000000..df65ee7 --- /dev/null +++ b/Core/Server/Models/Response/SppassOpenResponse.cs @@ -0,0 +1,22 @@ +using System.Xml.Serialization; + +namespace Server.Models.Response; + +[XmlRoot("sppass")] +public class SppassOpenResponse +{ + [XmlAttribute("status")] + public required int Status { get; set; } + + [XmlAttribute("token")] + public required string Token { get; set; } + + [XmlAttribute("expire_datetime")] + public required string ExpireDatetime { get; set; } + + [XmlAttribute("url")] + public required string Url { get; set; } + + [XmlAttribute("interval")] + public required int Interval { get; set; } +} diff --git a/Core/Server/Program.cs b/Core/Server/Program.cs index 688bd00..fdeb6de 100644 --- a/Core/Server/Program.cs +++ b/Core/Server/Program.cs @@ -4,6 +4,7 @@ using Abstractions.Utils; using KbinXml.Net; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Server; @@ -35,6 +36,14 @@ builder.Services.AddAuthorization(); builder.Services.AddIdentityApiEndpoints() .AddEntityFrameworkStores(); +// AddIdentityApiEndpoints registers a no-op IEmailSender by default; swap it for one that +// logs confirmation/reset links to the console until a real email sender is wired up. +// Must be Singleton: MapIdentityApi resolves it once via endpoints.ServiceProvider (the root +// container, at startup, before any request scope exists) and closes over it for the app's +// lifetime - a Scoped registration can't be resolved from the root container. +builder.Services.AddHttpContextAccessor(); +builder.Services.AddSingleton, ConsoleEmailSender>(); + builder.Services.AddHandlers(); if (!File.Exists("database/Medusa.db")) @@ -45,7 +54,8 @@ if (!File.Exists("database/Medusa.db")) } } -builder.Services.AddDbContext(options => options.UseSqlite("Data Source=database/Medusa.db;")); +builder.Services.AddDbContext(options => + options.UseSqlite("Data Source=database/Medusa.db;Default Timeout=5;")); builder.Services.AddIdentityCore(config => { @@ -88,6 +98,7 @@ builder.Services.AddSingleton(pluginUiBroadcaster); var pluginService = new PluginService(logger, pluginRegistry, pluginUiBroadcaster); builder.Services.AddSingleton(pluginRegistry); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(pluginService); builder.Services.AddHostedService(); @@ -139,6 +150,7 @@ app.UseHandlers(); var apiGroup = app.MapGroup("/api").WithTags("API"); apiGroup.MapCardsApiEndpoints(); apiGroup.MapUserApiEndpoints(); +apiGroup.MapCardlessApiEndpoints(); apiGroup.MapIdentityApi(); app.MapPluginStaticFiles(); apiGroup.MapPluginApiEndpoints(); @@ -154,6 +166,15 @@ eamuseGroup.MapPost("/{model}/{module}/{method}", (string model, string module, return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService); }); +eamuseGroup.MapPost("/{module}/{model}/{module2}/{method}", (string module, string model, string module2, string method, + HttpContext httpContext, [FromServices] ILogger logger, [FromServices] IHandlerService handlerService, + [FromServices] IPluginService pluginService) => +{ + var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" }; + + return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService); +}); + eamuseGroup.MapPost("/{m}", (string m, [FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method, [FromQuery] string? f, HttpContext httpContext, [FromServices] ILogger logger, [FromServices] IHandlerService handlerService, @@ -185,6 +206,8 @@ var appDbContext = scope.ServiceProvider.GetRequiredService(); await appDbContext.Database.MigrateAsync(); +await appDbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + app.Run(); return; @@ -252,9 +275,9 @@ async Task HandleEAmuseRequest(AmusementRequest request, string body, st var plugins = pluginService.GetPlugins(); var forcedEncoding = plugins.FirstOrDefault(p => p.GameCode == request.Model.Split(":")[0])?.ForcedEncoding; - var encodingEnum = encoding switch + var encodingEnum = encoding.ToLowerInvariant() switch { - "shift_jis" or "ShiftJIS" or "SHIFT_JIS" => KnownEncodings.ShiftJIS, + "shift_jis" or "shiftjis" => KnownEncodings.ShiftJIS, "us-ascii" => KnownEncodings.ASCII, "utf-8" => KnownEncodings.UTF8, "euc-jp" => KnownEncodings.EUC_JP, @@ -294,4 +317,4 @@ async Task HandleEAmuseRequest(AmusementRequest request, string body, st encodedBody = RC4.Encrypt(rc4Key, encodedBody); return encodedBody; -} \ No newline at end of file +} diff --git a/Core/Server/Services/HandlerService.cs b/Core/Server/Services/HandlerService.cs index 2de9f98..2b67b96 100644 --- a/Core/Server/Services/HandlerService.cs +++ b/Core/Server/Services/HandlerService.cs @@ -18,6 +18,8 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger Handle(string model, string module, string method, XDocument body) { + var gameModel = GameModel.Parse(model); + // ── Built-in server handlers (host SP) ────────────────────────────── foreach(var handler in Handlers) { @@ -27,7 +29,7 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger HandleInheritanceClass(Type handler, string model, string module, string method, XDocument body, IServiceProvider serviceProvider) + private async Task HandleInheritanceClass(Type handler, GameModel model, string module, string method, XDocument body, IServiceProvider serviceProvider) { var isHandlerWithoutRequest = false; @@ -122,8 +125,6 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger parsedMaxVer) + var parsedMaxVer = long.Parse(handlerMaxVer); + if(model.Version > parsedMaxVer) return null; } diff --git a/Core/Server/Services/ISppassSessionService.cs b/Core/Server/Services/ISppassSessionService.cs new file mode 100644 index 0000000..4fde267 --- /dev/null +++ b/Core/Server/Services/ISppassSessionService.cs @@ -0,0 +1,9 @@ +namespace Server.Services; + +public interface ISppassSessionService +{ + SppassSession Open(); + SppassSession? GetSession(string token); + bool TryApprove(string token, string cardType, string cardId); + void Close(string token); +} diff --git a/Core/Server/Services/SppassSessionService.cs b/Core/Server/Services/SppassSessionService.cs new file mode 100644 index 0000000..0cc180a --- /dev/null +++ b/Core/Server/Services/SppassSessionService.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Server.Services; + +public class SppassSessionService(IMemoryCache cache) : ISppassSessionService +{ + private static readonly TimeSpan SessionLifetime = TimeSpan.FromMinutes(3); + + private readonly IMemoryCache _cache = cache; + + public SppassSession Open() + { + var session = new SppassSession(GenerateToken(), DateTimeOffset.UtcNow.Add(SessionLifetime)); + _cache.Set(session.Token, session, session.ExpiresAt); + return session; + } + + public SppassSession? GetSession(string token) + => _cache.TryGetValue(token, out SppassSession? session) ? session : null; + + public bool TryApprove(string token, string cardType, string cardId) + { + var session = GetSession(token); + if (session is null) + { + return false; + } + + session.CardType = cardType; + session.CardId = cardId; + return true; + } + + public void Close(string token) + { + _cache.Remove(token); + } + + private static string GenerateToken() + => Guid.NewGuid().ToString().Replace("-", "").ToUpper(); +} + +public record SppassSession(string Token, DateTimeOffset ExpiresAt) +{ + public string? CardType { get; set; } + public string? CardId { get; set; } + public bool IsApproved => CardId is not null; +} diff --git a/Core/Server/Utils/ServerAddress.cs b/Core/Server/Utils/ServerAddress.cs new file mode 100644 index 0000000..d7b7057 --- /dev/null +++ b/Core/Server/Utils/ServerAddress.cs @@ -0,0 +1,7 @@ +namespace Server.Utils; + +public static class ServerAddress +{ + public static string PublicUrl => + Environment.GetEnvironmentVariable("MAIN_ADDRESS") ?? "http://localhost:5120"; +}