From 0790ad6fe02eb7f21ce36b50bd5f1c3ac74094ab Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 17 Jun 2026 09:03:12 +0330 Subject: [PATCH] chore(prod): real leaderboard, prod guards, payment hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production-readiness pass — remove mock-in-prod and harden the server: - leaderboard: new DB-backed LeaderboardService + /api/leaderboard (ranked by rating, 30s cache, bounded scan); client now calls it instead of mock fake data. - online count: client uses real /api/stats/online (dropped the fabricated ≥50 floor). - boot guards (Production): refuse to start if Sms:ApiKey is missing (OTP would run in dev mode = fixed code for any phone) or Iab:AllowUnverified is true (forged tokens could mint coins). - payments: ZarinPal + IAB HttpClients get 15s timeouts; ZarinPal/FlatPay gateway failures are now logged instead of silently swallowed. - OTP: periodic prune of expired codes + stale rate-limit logs (was an unbounded in-memory leak over a long-running process). - DB: EnableRetryOnFailure for Postgres (transient-fault resilience). - docker-compose: ZarinPal sandbox now defaults to false (real payments). Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 2 +- server/src/Hokm.Server/Auth/OtpService.cs | 31 +++++++- .../Hokm.Server/Payments/FlatPayService.cs | 10 ++- server/src/Hokm.Server/Payments/IabService.cs | 3 +- .../Hokm.Server/Payments/ZarinpalService.cs | 15 ++-- .../Profiles/LeaderboardService.cs | 72 +++++++++++++++++++ server/src/Hokm.Server/Program.cs | 19 ++++- src/lib/online/signalr-service.ts | 14 ++-- 8 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 server/src/Hokm.Server/Profiles/LeaderboardService.cs diff --git a/docker-compose.yml b/docker-compose.yml index 0e7b20f..9f62fdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: # Comma-separated origins the browser uses to reach the web app. Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500} Zarinpal__MerchantId: ${ZARINPAL_MERCHANT_ID:-299685fb-cadf-4dfc-98e2-d4af5d81528d} - Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true} + Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-false} Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback} Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500} # FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single diff --git a/server/src/Hokm.Server/Auth/OtpService.cs b/server/src/Hokm.Server/Auth/OtpService.cs index 4d72ec8..cbd03c1 100644 --- a/server/src/Hokm.Server/Auth/OtpService.cs +++ b/server/src/Hokm.Server/Auth/OtpService.cs @@ -39,7 +39,7 @@ public sealed class SmsOptions public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds); /// Generates, sends (Kavenegar) and verifies phone OTP codes. -public sealed class OtpService +public sealed class OtpService : IDisposable { private static readonly HttpClient Http = new(); private readonly SmsOptions _opts; @@ -50,6 +50,9 @@ public sealed class OtpService private readonly ConcurrentDictionary> _sendLog = new(); private readonly object _globalLock = new(); private readonly List _globalLog = new(); + // Periodic prune so expired codes / stale rate-limit logs don't accumulate + // unboundedly over a long-running process. + private readonly Timer _cleanup; private readonly record struct Entry(string Code, DateTime Expires, int Tries); @@ -57,8 +60,34 @@ public sealed class OtpService { _opts = opts; _log = log; + _cleanup = new Timer(_ => Prune(), null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } + /// Drop expired OTP codes and stale (>1h) rate-limit entries. + private void Prune() + { + try + { + var now = DateTime.UtcNow; + foreach (var kv in _codes) + if (now > kv.Value.Expires) _codes.TryRemove(kv.Key, out _); + + var hour = TimeSpan.FromHours(1); + foreach (var kv in _sendLog) + { + lock (kv.Value) + { + kv.Value.RemoveAll(t => now - t >= hour); + if (kv.Value.Count == 0) _sendLog.TryRemove(kv.Key, out _); + } + } + lock (_globalLock) _globalLog.RemoveAll(t => now - t >= hour); + } + catch (Exception ex) { _log.LogWarning(ex, "OTP prune failed"); } + } + + public void Dispose() => _cleanup.Dispose(); + /// Dev mode = explicitly on, or no API key configured. public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey); diff --git a/server/src/Hokm.Server/Payments/FlatPayService.cs b/server/src/Hokm.Server/Payments/FlatPayService.cs index 7829fbc..ca7d40a 100644 --- a/server/src/Hokm.Server/Payments/FlatPayService.cs +++ b/server/src/Hokm.Server/Payments/FlatPayService.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Logging; namespace Hokm.Server.Payments; @@ -29,10 +30,15 @@ public sealed class FlatPayService { private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) }; private readonly FlatPayOptions _opts; + private readonly ILogger _log; // Idempotency: broker webhooks may be delivered more than once. private readonly ConcurrentDictionary _processed = new(); - public FlatPayService(FlatPayOptions opts) => _opts = opts; + public FlatPayService(FlatPayOptions opts, ILogger log) + { + _opts = opts; + _log = log; + } public bool Enabled => !string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret); @@ -72,7 +78,7 @@ public sealed class FlatPayService doc.RootElement.TryGetProperty("payment_url", out var url)) return url.GetString(); } - catch { /* broker unreachable */ } + catch (Exception ex) { _log.LogWarning(ex, "FlatPay broker payment request failed"); } return null; } diff --git a/server/src/Hokm.Server/Payments/IabService.cs b/server/src/Hokm.Server/Payments/IabService.cs index 36d8771..372182a 100644 --- a/server/src/Hokm.Server/Payments/IabService.cs +++ b/server/src/Hokm.Server/Payments/IabService.cs @@ -45,7 +45,8 @@ public sealed class IabOptions /// public sealed class IabService { - private static readonly HttpClient Http = new(); + // Bounded timeout so a hung store API can't tie up request threads. + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; private readonly IabOptions _opts; private readonly ILogger _log; diff --git a/server/src/Hokm.Server/Payments/ZarinpalService.cs b/server/src/Hokm.Server/Payments/ZarinpalService.cs index 4e10b71..e73c506 100644 --- a/server/src/Hokm.Server/Payments/ZarinpalService.cs +++ b/server/src/Hokm.Server/Payments/ZarinpalService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.Extensions.Logging; namespace Hokm.Server.Payments; @@ -22,11 +23,17 @@ public sealed record PendingPayment(string UserId, string PackId, int AmountRial /// public sealed class ZarinpalService { - private static readonly HttpClient Http = new(); + // Bounded timeout so a hung gateway can't tie up request threads. + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; private readonly ZarinpalOptions _opts; + private readonly ILogger _log; private readonly ConcurrentDictionary _pending = new(); - public ZarinpalService(ZarinpalOptions opts) => _opts = opts; + public ZarinpalService(ZarinpalOptions opts, ILogger log) + { + _opts = opts; + _log = log; + } public string ClientReturnUrl => _opts.ClientReturnUrl; @@ -59,7 +66,7 @@ public sealed class ZarinpalService return $"{Base}/pg/StartPay/{authority}"; } } - catch { /* gateway unreachable */ } + catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment request failed for user {User}", userId); } return null; } @@ -82,7 +89,7 @@ public sealed class ZarinpalService return pending; } } - catch { /* gateway unreachable */ } + catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment verify failed for authority {Authority}", authority); } return null; } } diff --git a/server/src/Hokm.Server/Profiles/LeaderboardService.cs b/server/src/Hokm.Server/Profiles/LeaderboardService.cs new file mode 100644 index 0000000..dc57ca5 --- /dev/null +++ b/server/src/Hokm.Server/Profiles/LeaderboardService.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Hokm.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace Hokm.Server.Profiles; + +public record LeaderboardEntryDto( + int Rank, string Id, string DisplayName, string Avatar, string? AvatarImage, + int Level, int Rating, double LevelProgress, bool IsYou); + +/// +/// Real, DB-backed leaderboard. Profiles are stored as JSON blobs (no rating +/// column to ORDER BY), so we load and rank in memory behind a short cache to +/// keep it cheap under load. Bounded scan so a large table can't exhaust memory. +/// +public sealed class LeaderboardService +{ + private sealed record Row(string Id, string Name, string Avatar, string? Img, int Level, int Xp, int Rating); + + private readonly IServiceScopeFactory _scopes; + private readonly object _lock = new(); + private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30); + private List _cache = new(); + private DateTime _cachedAt = DateTime.MinValue; + + public LeaderboardService(IServiceScopeFactory scopes) => _scopes = scopes; + + private List Snapshot() + { + lock (_lock) + { + if (_cache.Count > 0 && DateTime.UtcNow - _cachedAt < Ttl) return _cache; + } + + var list = new List(); + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Cap the scan; ranking is by rating which lives inside the JSON blob. + var rows = db.Profiles.AsNoTracking().Take(5000).ToList(); + foreach (var r in rows) + { + try + { + var p = JsonSerializer.Deserialize(r.Json, JsonOpts.Default); + if (p == null) continue; + list.Add(new Row( + string.IsNullOrEmpty(p.Id) ? r.Id : p.Id, + p.DisplayName, p.Avatar, p.AvatarImage, p.Level, p.Xp, p.Rating)); + } + catch { /* skip malformed rows */ } + } + var top = list.OrderByDescending(x => x.Rating).ThenByDescending(x => x.Level).Take(100).ToList(); + lock (_lock) { _cache = top; _cachedAt = DateTime.UtcNow; } + return top; + } + + public List Top(string? meId) + { + var snap = Snapshot(); + var result = new List(snap.Count); + for (int i = 0; i < snap.Count; i++) + { + var r = snap[i]; + var need = Gamification.XpForLevel(r.Level); + var progress = need > 0 ? Math.Clamp((double)r.Xp / need, 0, 1) : 0; + result.Add(new LeaderboardEntryDto( + i + 1, r.Id, r.Name, r.Avatar, r.Img, r.Level, r.Rating, progress, + meId != null && r.Id == meId)); + } + return result; + } +} diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index ffff5bc..716b6c2 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -34,12 +34,15 @@ var dbConn = builder.Configuration.GetConnectionString("Default"); builder.Services.AddDbContext(o => { if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase)) - o.UseNpgsql(dbConn ?? ""); + // Retry transient Postgres failures (network blips, DB restarts) so a + // brief outage doesn't surface as request errors in production. + o.UseNpgsql(dbConn ?? "", npg => npg.EnableRetryOnFailure(maxRetryCount: 5)); else o.UseSqlite(dbConn ?? "Data Source=hokm.db"); }); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) --- var zp = builder.Configuration.GetSection("Zarinpal").Get() ?? new ZarinpalOptions(); @@ -56,11 +59,21 @@ builder.Services.AddSingleton(); // --- Store in-app billing (Cafe Bazaar / Myket) verification --- var iab = builder.Configuration.GetSection("Iab").Get() ?? new IabOptions(); +// Production guard: AllowUnverified credits coins WITHOUT verifying the purchase +// with the store — a forged token could mint coins. Never allow it in prod. +if (builder.Environment.IsProduction() && iab.AllowUnverified) + throw new InvalidOperationException( + "Iab:AllowUnverified (env IAB_ALLOW_UNVERIFIED) must be false in Production."); builder.Services.AddSingleton(iab); builder.Services.AddSingleton(); // --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). --- var sms = builder.Configuration.GetSection("Sms").Get() ?? new SmsOptions(); +// Production guard: with no API key the OTP service runs in DEV mode (accepts a +// fixed code for ANY phone), which would let anyone log in. Require a real key. +if (builder.Environment.IsProduction() && string.IsNullOrWhiteSpace(sms.ApiKey)) + throw new InvalidOperationException( + "Sms:ApiKey (env SMS_API_KEY) is mandatory in Production — without it OTP runs in dev mode."); builder.Services.AddSingleton(sms); builder.Services.AddSingleton(); @@ -214,6 +227,10 @@ app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialSer app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) => Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization(); +// Real, DB-backed leaderboard (top players by rating). +app.MapGet("/api/leaderboard", (ClaimsPrincipal u, LeaderboardService lb) => + Results.Json(lb.Top(Uid(u)), JsonOpts.Default)).RequireAuthorization(); + app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) => Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default)) .RequireAuthorization(); diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index b4e5f46..c61ab0a 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -547,21 +547,23 @@ export class SignalrService implements OnlineService { } async getOnlineCount(): Promise { - // Always show a believable floor (≥50) — never the raw small/zero real count. - const floor = await this.mock.getOnlineCount(); // drifts, min 50 + // Real count from the server (no fabricated floor). try { const res = await fetch(`${SERVER}/api/stats/online`); if (res.ok) { const j = (await res.json()) as { online: number }; - return Math.max(j.online ?? 0, floor); + return Math.max(0, j.online ?? 0); } } catch { - /* fall through */ + /* server unreachable */ } - return floor; + return 0; } - getLeaderboard(): Promise { return this.mock.getLeaderboard(); } + async getLeaderboard(): Promise { + // Real, server-ranked leaderboard. + return this.getJson("/api/leaderboard"); + } // shop catalog stays client-side; the purchase is server-authoritative getShopItems(): Promise { return this.mock.getShopItems(); }