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