using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
///
/// Tracks "I want to play" requests from friends, pending admin approval.
/// State is a flat JSON file in the server dir so it survives daemon restarts
/// without needing a database. Single-flight gate prevents concurrent-write
/// corruption when admin and friend act at the same time.
///
/// State machine: (none) -> pending -> approved | denied
/// "approved" means the admin clicked Approve; the actual /whitelist add
/// command goes through the existing whitelist endpoint, which removes the
/// request from the pending list.
///
public sealed class WhitelistRequestService
{
private readonly ServerConfig _config;
private readonly object _lock = new();
public WhitelistRequestService(ServerConfig config) => _config = config;
public sealed class Request
{
public string Username { get; set; } = "";
public string? Message { get; set; }
public string Status { get; set; } = "pending"; // pending | approved | denied
public DateTimeOffset RequestedAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public string? RemoteIp { get; set; } // for admin diagnosis if needed
}
private string FilePath =>
Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist-requests.json");
private List Load()
{
try
{
if (!File.Exists(FilePath)) return new();
var text = File.ReadAllText(FilePath);
if (string.IsNullOrWhiteSpace(text)) return new();
return JsonSerializer.Deserialize>(text, JsonOpts.CaseInsensitive) ?? new();
}
catch { return new(); }
}
private void Save(List list)
{
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
File.WriteAllText(FilePath, text);
}
public IReadOnlyList List() { lock (_lock) return Load(); }
public IReadOnlyList ListPending()
{
lock (_lock)
return Load().Where(r => r.Status == "pending").ToList();
}
/// Submit a new request. Idempotent on (username, status=pending) -- won't dupe.
public Request Submit(string username, string? message, string? remoteIp)
{
lock (_lock)
{
var list = Load();
// Drop any prior request for this username (case-insensitive) so the
// most recent one wins regardless of previous state. Keeps the file
// from growing if a friend re-requests after a denial.
list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
var req = new Request
{
Username = username,
Message = string.IsNullOrWhiteSpace(message) ? null : message,
Status = "pending",
RequestedAt = DateTimeOffset.UtcNow,
RemoteIp = remoteIp,
};
list.Add(req);
Save(list);
return req;
}
}
///
/// Effective status for the launcher. If the username is in the actual
/// whitelist.json (regardless of whether they ever filed a request), returns
/// "approved" -- that's what the friend's launcher cares about. Otherwise
/// falls back to whatever request record we have, or "unknown".
///
public string StatusFor(string username)
{
if (IsActuallyWhitelisted(username)) return "approved";
lock (_lock)
{
var match = Load().FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
return match?.Status ?? "unknown";
}
}
private bool IsActuallyWhitelisted(string username)
{
var path = Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist.json");
if (!File.Exists(path)) return false;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.EnumerateArray().Any(e =>
e.TryGetProperty("name", out var n) &&
string.Equals(n.GetString(), username, StringComparison.OrdinalIgnoreCase));
}
catch { return false; }
}
public bool MarkApproved(string username) => SetStatus(username, "approved");
public bool MarkDenied(string username) => SetStatus(username, "denied");
/// Remove the request entirely (used after the actual /whitelist add fires).
public bool Remove(string username)
{
lock (_lock)
{
var list = Load();
var removed = list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (removed > 0) Save(list);
return removed > 0;
}
}
private bool SetStatus(string username, string status)
{
lock (_lock)
{
var list = Load();
var match = list.FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (match is null) return false;
match.Status = status;
match.ResolvedAt = DateTimeOffset.UtcNow;
Save(list);
return true;
}
}
}