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(); }