chore(prod): real leaderboard, prod guards, payment hardening
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) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -53,7 +53,7 @@ services:
|
|||||||
# Comma-separated origins the browser uses to reach the web app.
|
# Comma-separated origins the browser uses to reach the web app.
|
||||||
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
|
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
|
||||||
Zarinpal__MerchantId: ${ZARINPAL_MERCHANT_ID:-299685fb-cadf-4dfc-98e2-d4af5d81528d}
|
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__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback}
|
||||||
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500}
|
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500}
|
||||||
# FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single
|
# FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public sealed class SmsOptions
|
|||||||
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
|
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
|
||||||
|
|
||||||
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
|
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
|
||||||
public sealed class OtpService
|
public sealed class OtpService : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly HttpClient Http = new();
|
private static readonly HttpClient Http = new();
|
||||||
private readonly SmsOptions _opts;
|
private readonly SmsOptions _opts;
|
||||||
@@ -50,6 +50,9 @@ public sealed class OtpService
|
|||||||
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
|
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
|
||||||
private readonly object _globalLock = new();
|
private readonly object _globalLock = new();
|
||||||
private readonly List<DateTime> _globalLog = new();
|
private readonly List<DateTime> _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);
|
private readonly record struct Entry(string Code, DateTime Expires, int Tries);
|
||||||
|
|
||||||
@@ -57,8 +60,34 @@ public sealed class OtpService
|
|||||||
{
|
{
|
||||||
_opts = opts;
|
_opts = opts;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_cleanup = new Timer(_ => Prune(), null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Drop expired OTP codes and stale (>1h) rate-limit entries.</summary>
|
||||||
|
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();
|
||||||
|
|
||||||
/// <summary>Dev mode = explicitly on, or no API key configured.</summary>
|
/// <summary>Dev mode = explicitly on, or no API key configured.</summary>
|
||||||
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
|
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Hokm.Server.Payments;
|
namespace Hokm.Server.Payments;
|
||||||
|
|
||||||
@@ -29,10 +30,15 @@ public sealed class FlatPayService
|
|||||||
{
|
{
|
||||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||||
private readonly FlatPayOptions _opts;
|
private readonly FlatPayOptions _opts;
|
||||||
|
private readonly ILogger<FlatPayService> _log;
|
||||||
// Idempotency: broker webhooks may be delivered more than once.
|
// Idempotency: broker webhooks may be delivered more than once.
|
||||||
private readonly ConcurrentDictionary<string, byte> _processed = new();
|
private readonly ConcurrentDictionary<string, byte> _processed = new();
|
||||||
|
|
||||||
public FlatPayService(FlatPayOptions opts) => _opts = opts;
|
public FlatPayService(FlatPayOptions opts, ILogger<FlatPayService> log)
|
||||||
|
{
|
||||||
|
_opts = opts;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
public bool Enabled =>
|
public bool Enabled =>
|
||||||
!string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret);
|
!string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret);
|
||||||
@@ -72,7 +78,7 @@ public sealed class FlatPayService
|
|||||||
doc.RootElement.TryGetProperty("payment_url", out var url))
|
doc.RootElement.TryGetProperty("payment_url", out var url))
|
||||||
return url.GetString();
|
return url.GetString();
|
||||||
}
|
}
|
||||||
catch { /* broker unreachable */ }
|
catch (Exception ex) { _log.LogWarning(ex, "FlatPay broker payment request failed"); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ public sealed class IabOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IabService
|
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 IabOptions _opts;
|
||||||
private readonly ILogger<IabService> _log;
|
private readonly ILogger<IabService> _log;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Hokm.Server.Payments;
|
namespace Hokm.Server.Payments;
|
||||||
|
|
||||||
@@ -22,11 +23,17 @@ public sealed record PendingPayment(string UserId, string PackId, int AmountRial
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ZarinpalService
|
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 ZarinpalOptions _opts;
|
||||||
|
private readonly ILogger<ZarinpalService> _log;
|
||||||
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
|
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
|
||||||
|
|
||||||
public ZarinpalService(ZarinpalOptions opts) => _opts = opts;
|
public ZarinpalService(ZarinpalOptions opts, ILogger<ZarinpalService> log)
|
||||||
|
{
|
||||||
|
_opts = opts;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
public string ClientReturnUrl => _opts.ClientReturnUrl;
|
public string ClientReturnUrl => _opts.ClientReturnUrl;
|
||||||
|
|
||||||
@@ -59,7 +66,7 @@ public sealed class ZarinpalService
|
|||||||
return $"{Base}/pg/StartPay/{authority}";
|
return $"{Base}/pg/StartPay/{authority}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* gateway unreachable */ }
|
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment request failed for user {User}", userId); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ public sealed class ZarinpalService
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* gateway unreachable */ }
|
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment verify failed for authority {Authority}", authority); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Row> _cache = new();
|
||||||
|
private DateTime _cachedAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
public LeaderboardService(IServiceScopeFactory scopes) => _scopes = scopes;
|
||||||
|
|
||||||
|
private List<Row> Snapshot()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_cache.Count > 0 && DateTime.UtcNow - _cachedAt < Ttl) return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<Row>();
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
// 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<ProfileDto>(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<LeaderboardEntryDto> Top(string? meId)
|
||||||
|
{
|
||||||
|
var snap = Snapshot();
|
||||||
|
var result = new List<LeaderboardEntryDto>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,12 +34,15 @@ var dbConn = builder.Configuration.GetConnectionString("Default");
|
|||||||
builder.Services.AddDbContext<AppDbContext>(o =>
|
builder.Services.AddDbContext<AppDbContext>(o =>
|
||||||
{
|
{
|
||||||
if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
|
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
|
else
|
||||||
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<ProfileService>();
|
builder.Services.AddScoped<ProfileService>();
|
||||||
builder.Services.AddScoped<SocialService>();
|
builder.Services.AddScoped<SocialService>();
|
||||||
|
builder.Services.AddSingleton<LeaderboardService>();
|
||||||
|
|
||||||
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
||||||
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
|
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
|
||||||
@@ -56,11 +59,21 @@ builder.Services.AddSingleton<FlatPayService>();
|
|||||||
|
|
||||||
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
|
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
|
||||||
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
|
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? 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(iab);
|
||||||
builder.Services.AddSingleton<IabService>();
|
builder.Services.AddSingleton<IabService>();
|
||||||
|
|
||||||
// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). ---
|
// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). ---
|
||||||
var sms = builder.Configuration.GetSection("Sms").Get<SmsOptions>() ?? new SmsOptions();
|
var sms = builder.Configuration.GetSection("Sms").Get<SmsOptions>() ?? 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(sms);
|
||||||
builder.Services.AddSingleton<OtpService>();
|
builder.Services.AddSingleton<OtpService>();
|
||||||
|
|
||||||
@@ -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) =>
|
app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) =>
|
||||||
Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
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) =>
|
app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||||
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
|
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
|
||||||
.RequireAuthorization();
|
.RequireAuthorization();
|
||||||
|
|||||||
@@ -547,21 +547,23 @@ export class SignalrService implements OnlineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOnlineCount(): Promise<number> {
|
async getOnlineCount(): Promise<number> {
|
||||||
// Always show a believable floor (≥50) — never the raw small/zero real count.
|
// Real count from the server (no fabricated floor).
|
||||||
const floor = await this.mock.getOnlineCount(); // drifts, min 50
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${SERVER}/api/stats/online`);
|
const res = await fetch(`${SERVER}/api/stats/online`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const j = (await res.json()) as { online: number };
|
const j = (await res.json()) as { online: number };
|
||||||
return Math.max(j.online ?? 0, floor);
|
return Math.max(0, j.online ?? 0);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* server unreachable */
|
||||||
}
|
}
|
||||||
return floor;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
async getLeaderboard(): Promise<LeaderboardEntry[]> {
|
||||||
|
// Real, server-ranked leaderboard.
|
||||||
|
return this.getJson<LeaderboardEntry[]>("/api/leaderboard");
|
||||||
|
}
|
||||||
|
|
||||||
// shop catalog stays client-side; the purchase is server-authoritative
|
// shop catalog stays client-side; the purchase is server-authoritative
|
||||||
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user