a1331212cb
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
146 lines
5.3 KiB
C#
146 lines
5.3 KiB
C#
using System.Text.Json;
|
|
using BrassAndSigil.Server.Models;
|
|
|
|
namespace BrassAndSigil.Server.Services;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Request> Load()
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(FilePath)) return new();
|
|
var text = File.ReadAllText(FilePath);
|
|
if (string.IsNullOrWhiteSpace(text)) return new();
|
|
return JsonSerializer.Deserialize<List<Request>>(text, JsonOpts.CaseInsensitive) ?? new();
|
|
}
|
|
catch { return new(); }
|
|
}
|
|
|
|
private void Save(List<Request> list)
|
|
{
|
|
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
|
|
File.WriteAllText(FilePath, text);
|
|
}
|
|
|
|
public IReadOnlyList<Request> List() { lock (_lock) return Load(); }
|
|
|
|
public IReadOnlyList<Request> ListPending()
|
|
{
|
|
lock (_lock)
|
|
return Load().Where(r => r.Status == "pending").ToList();
|
|
}
|
|
|
|
/// <summary>Submit a new request. Idempotent on (username, status=pending) -- won't dupe.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
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");
|
|
|
|
/// <summary>Remove the request entirely (used after the actual /whitelist add fires).</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|