Marketing site (bargevasat.ir) + admin-editable store links + subdomain split
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m40s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 41s

- New standalone Next.js marketing site under site/ (static export, SEO):
  landing, download/install guide (Bazaar/Myket/iOS-PWA/web), FAQ (JSON-LD),
  privacy, terms, support, /admin link editor. fa RTL, sitemap/robots/manifest.
- Backend: SiteLinksService (JSON-file persisted) + GET /api/site/links (public)
  + POST /api/admin/site/links (X-Admin-Token). ADMIN_TOKEN + Site__DataDir via env.
- compose: hokm-site service (:1520) + hokm_data volume for links JSON.
- CI deploy job builds + deploys the site container.
- deploy/SUBDOMAIN_SPLIT.md: nginx blocks, cert reissue, DNS, ENV split.
- Exclude site/ from root tsc + web docker context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 07:19:43 +03:30
parent 8d0d4dc991
commit 5d38312ef0
39 changed files with 8207 additions and 2 deletions
+16
View File
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Payments;
using Hokm.Server.Profiles;
using Hokm.Server.Site;
using Hokm.Server.Social;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
@@ -51,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOp
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- Marketing site links (admin-editable) + shared-token admin auth ---
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
builder.Services.AddSingleton(admin);
builder.Services.AddSingleton<SiteLinksService>();
// --- SignalR (camelCase to match the TS client) ---
builder.Services
.AddSignalR()
@@ -126,6 +132,16 @@ app.UseAuthorization();
app.MapGet("/", () => Results.Json(new { service = "Barg-e Vasat SignalR server", status = "ok" }));
app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m.OnlineCount }));
// --- Marketing site links: public read, admin-token write ---
app.MapGet("/api/site/links", (SiteLinksService s) => Results.Json(s.Get(), JsonOpts.Default));
app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteLinksService s, SiteLinks body) =>
{
var token = req.Headers["X-Admin-Token"].ToString();
if (string.IsNullOrWhiteSpace(admin.Token) || token != admin.Token)
return Results.Json(new { error = "UNAUTHORIZED" }, statusCode: 401);
return Results.Json(s.Update(body), JsonOpts.Default);
});
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
Results.Json(new { devCode = "1234", phone = req.Phone }));
@@ -0,0 +1,120 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Hokm.Server.Site;
/// <summary>Admin-editable links + flags shown on the marketing site (bargevasat.ir).</summary>
public class SiteLinks
{
// Android stores
public string BazaarUrl { get; set; } = "";
public bool BazaarEnabled { get; set; } = false;
public string MyketUrl { get; set; } = "";
public bool MyketEnabled { get; set; } = false;
// Direct APK (optional, for sideloading)
public string DirectApkUrl { get; set; } = "";
public bool DirectApkEnabled { get; set; } = false;
// Play on web / PWA
public string WebPlayUrl { get; set; } = "https://app.bargevasat.ir";
public bool IosPwaEnabled { get; set; } = true; // iOS = Add to Home Screen
// Socials / support
public string Instagram { get; set; } = "";
public string Telegram { get; set; } = "";
public string SupportEmail { get; set; } = "";
public string SupportPhone { get; set; } = "";
public string AppVersion { get; set; } = "";
}
/// <summary>Shared-token admin auth (set ADMIN_TOKEN in ENV_FILE).</summary>
public class AdminOptions
{
public string Token { get; set; } = "";
}
/// <summary>
/// Loads/persists <see cref="SiteLinks"/> as a JSON file under a writable data dir
/// (mount a volume at it in prod). No DB migration required.
/// </summary>
public class SiteLinksService
{
private readonly string _path;
private readonly object _gate = new();
private SiteLinks _current;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = true,
};
public SiteLinksService(IConfiguration config)
{
var dataDir = config["Site:DataDir"];
if (string.IsNullOrWhiteSpace(dataDir)) dataDir = "/data";
try { Directory.CreateDirectory(dataDir); } catch { /* fall back below */ }
if (!CanWrite(dataDir)) dataDir = AppContext.BaseDirectory; // dev fallback
_path = Path.Combine(dataDir, "site-links.json");
_current = Load() ?? Seed(config);
// Persist the seed so the file exists for the admin to edit.
if (!File.Exists(_path)) TrySave(_current);
}
public SiteLinks Get()
{
lock (_gate) return Clone(_current);
}
public SiteLinks Update(SiteLinks next)
{
lock (_gate)
{
_current = next;
TrySave(_current);
return Clone(_current);
}
}
private SiteLinks? Load()
{
try
{
if (!File.Exists(_path)) return null;
return JsonSerializer.Deserialize<SiteLinks>(File.ReadAllText(_path), JsonOpts);
}
catch { return null; }
}
private void TrySave(SiteLinks v)
{
try { File.WriteAllText(_path, JsonSerializer.Serialize(v, JsonOpts)); }
catch { /* read-only fs in dev — keep in-memory only */ }
}
// Seed defaults from config (Site section) when no file exists yet.
private static SiteLinks Seed(IConfiguration config)
{
var seeded = config.GetSection("Site:Links").Get<SiteLinks>();
return seeded ?? new SiteLinks();
}
private static SiteLinks Clone(SiteLinks v) =>
JsonSerializer.Deserialize<SiteLinks>(JsonSerializer.Serialize(v, JsonOpts), JsonOpts)!;
private static bool CanWrite(string dir)
{
try
{
var probe = Path.Combine(dir, ".write-test");
File.WriteAllText(probe, "ok");
File.Delete(probe);
return true;
}
catch { return false; }
}
}