using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// Reads and writes Minecraft's server.properties. Editable keys are /// gated by an allowlist so a compromised panel can't flip security-critical /// fields like online-mode arbitrarily -- only common gameplay knobs. /// Preserves comments and key order on write; appends new keys at the end. /// public sealed class ServerPropertiesService { private readonly ServerConfig _config; public ServerPropertiesService(ServerConfig config) => _config = config; /// /// Keys that may be modified via /api/server/settings. Anything outside this /// set is silently dropped from the update payload -- admin must SSH for those. /// public static readonly IReadOnlySet EditableKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "motd", "difficulty", "gamemode", "view-distance", "simulation-distance", "max-players", "pvp", "hardcore", "white-list", "enforce-whitelist", "allow-flight", "enable-command-block", "spawn-protection", }; public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties"); public Dictionary ReadAll() { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!File.Exists(PropertiesPath)) return dict; foreach (var raw in File.ReadAllLines(PropertiesPath)) { var line = raw.TrimStart(); if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue; var idx = line.IndexOf('='); if (idx < 0) continue; dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1); } return dict; } /// Returns just the editable subset, with values left as raw strings. public Dictionary ReadEditable() { var all = ReadAll(); var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var key in EditableKeys) { if (all.TryGetValue(key, out var v)) result[key] = v; } return result; } /// /// Read the current level-seed value, or null if absent / empty. /// public string? GetLevelSeed() { if (!File.Exists(PropertiesPath)) return null; foreach (var raw in File.ReadAllLines(PropertiesPath)) { var line = raw.TrimStart(); if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue; var v = line.Substring("level-seed=".Length).Trim(); return string.IsNullOrEmpty(v) ? null : v; } return null; } /// /// Direct write of level-seed. Bypasses /// because the seed is set as part of the wipe flow (with confirmation), /// not by general settings UI -- exposing it through the regular Update() /// path would let it be flipped from any settings save. Empty string /// clears the field, which makes Minecraft pick a random seed on next /// world generation. /// public void SetLevelSeed(string seed) { var lines = File.Exists(PropertiesPath) ? File.ReadAllLines(PropertiesPath).ToList() : new List(); var done = false; for (int i = 0; i < lines.Count; i++) { var trimmed = lines[i].TrimStart(); if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal)) { lines[i] = $"level-seed={seed}"; done = true; break; } } if (!done) lines.Add($"level-seed={seed}"); File.WriteAllLines(PropertiesPath, lines); } /// /// Apply updates to the file. Keys not in are /// silently dropped. Lines that already exist are updated in-place to /// preserve order and comments; new keys are appended at the end. /// public void Update(IDictionary updates) { // Filter to allowed keys only. var filtered = updates .Where(kv => EditableKeys.Contains(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); if (filtered.Count == 0) return; var lines = File.Exists(PropertiesPath) ? File.ReadAllLines(PropertiesPath).ToList() : new List(); var applied = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < lines.Count; i++) { var raw = lines[i]; var trimmed = raw.TrimStart(); if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue; var idx = trimmed.IndexOf('='); if (idx < 0) continue; var key = trimmed.Substring(0, idx).Trim(); if (filtered.TryGetValue(key, out var newValue)) { lines[i] = $"{key}={newValue}"; applied.Add(key); } } foreach (var (key, value) in filtered) { if (!applied.Contains(key)) lines.Add($"{key}={value}"); } File.WriteAllLines(PropertiesPath, lines); } }