Marketing site (bargevasat.ir) + admin-editable store links + subdomain split
- 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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user