using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; namespace Oxide.Plugins { [Info("BigKidsCore", "BigKids.pro", "1.0.0")] [Description("Shared core for BigKids.pro server plugins: one server token, HTTP to the API, identity/cfg helpers and heartbeat. Install this once; other BigKids.pro plugins require it.")] public class BigKidsCore : CovalencePlugin { // Bump on a breaking change to the methods exposed to dependent plugins. private const int CORE_API = 1; #region Configuration private Configuration config; private class Configuration { [JsonProperty("API base URL")] public string ApiBaseUrl = "https://bigkids.pro"; [JsonProperty("Server token (from your bigkids.pro profile)")] public string ServerToken = "PASTE-YOUR-SERVER-TOKEN-HERE"; [JsonProperty("Heartbeat interval (minutes)")] public float HeartbeatMinutes = 30f; } protected override void LoadDefaultConfig() => config = new Configuration(); protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) LoadDefaultConfig(); } catch { PrintWarning("Config is invalid, loading defaults."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); #endregion #region Lifecycle private Timer heartbeatTimer; private bool unloaded; private void Init() { permission.RegisterPermission("bigkidscore.admin", this); } private void OnServerInitialized() { if (!IsReady()) PrintWarning("Server token is not configured (or API URL is not https). Set it in oxide/config/BigKidsCore.json."); float interval = Math.Max(300f, config.HeartbeatMinutes * 60f); heartbeatTimer = timer.Every(interval, SendHeartbeat); timer.Once(15f, SendHeartbeat); } private void Unload() { unloaded = true; heartbeatTimer?.Destroy(); } #endregion #region Public API (called by dependent plugins via Call) // The core API version dependent plugins check for compatibility. [HookMethod("CoreApiVersion")] public int CoreApiVersion() => CORE_API; // True when a server token is set and the API URL is https. [HookMethod("IsReady")] public bool IsReady() => TokenReady() && ApiUrlSecure(); // GET {base}{path} with the server token. callback(httpCode, body). [HookMethod("ApiGet")] public void ApiGet(string path, Action callback) { Request(path, null, RequestMethod.GET, callback); } // POST {base}{path} with a JSON body and the server token. callback(httpCode, body). [HookMethod("ApiPost")] public void ApiPost(string path, string jsonBody, Action callback) { Request(path, jsonBody, RequestMethod.POST, callback); } // The server's real save-folder identity (authoritative: from the command line). [HookMethod("ServerIdentity")] public string ServerIdentity() { try { string[] args = Environment.GetCommandLineArgs(); for (int i = 0; i + 1 < args.Length; i++) if (args[i].Equals("+server.identity", StringComparison.OrdinalIgnoreCase)) return args[i + 1]; } catch { } try { string id = ConVar.Server.identity; if (!string.IsNullOrEmpty(id)) return id; } catch { } return "server"; } // Writes server.levelurl into server//cfg/server.cfg (loads on next restart). [HookMethod("WriteServerCfgLevelUrl")] public void WriteServerCfgLevelUrl(string levelUrl) { if (levelUrl != null && levelUrl.Length > 0 && !IsValidMapUrl(levelUrl)) { PrintWarning($"Refusing to write a suspicious level_url: {levelUrl}"); return; } WriteCfg(levelUrl, null); } // Writes server.seed (and clears server.levelurl) — for procedural fallback. [HookMethod("WriteServerCfgSeed")] public void WriteServerCfgSeed(int seed) { WriteCfg(null, seed); } // Only a plain https URL, no quotes / line breaks (prevents cfg-line injection). [HookMethod("IsValidMapUrl")] public bool IsValidMapUrl(string url) { if (string.IsNullOrEmpty(url)) return false; if (url.IndexOf('"') >= 0 || url.IndexOf('\n') >= 0 || url.IndexOf('\r') >= 0) return false; return url.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } #endregion #region HTTP private void Request(string path, string body, RequestMethod method, Action callback) { if (!TokenReady()) { PrintWarning("Server token is not configured — request skipped."); callback?.Invoke(0, null); return; } if (!ApiUrlSecure()) { PrintWarning("API base URL must be https:// — refusing to send the token insecurely."); callback?.Invoke(0, null); return; } string url = config.ApiBaseUrl.TrimEnd('/') + path; var headers = new Dictionary { ["Authorization"] = "Bearer " + config.ServerToken, ["Accept"] = "application/json", }; if (method == RequestMethod.POST) headers["Content-Type"] = "application/json"; webrequest.Enqueue(url, body, (code, response) => { if (unloaded) return; callback?.Invoke(code, response); }, this, method, headers, 15f); } private bool TokenReady() => !string.IsNullOrEmpty(config.ServerToken) && !config.ServerToken.Contains("PASTE"); private bool ApiUrlSecure() => !string.IsNullOrEmpty(config.ApiBaseUrl) && config.ApiBaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase); #endregion #region Heartbeat private void SendHeartbeat() { if (!IsReady()) return; var plugins = new List>(); foreach (Plugin p in Interface.Oxide.RootPluginManager.GetPlugins()) { if (p?.Author == "BigKids.pro") plugins.Add(new Dictionary { ["name"] = p.Name, ["version"] = p.Version.ToString(), }); } var payload = new Dictionary { ["core_version"] = Version.ToString(), ["plugins"] = plugins, ["info"] = CollectServerInfo(), }; ApiPost("/api/v1/heartbeat", JsonConvert.SerializeObject(payload), (code, response) => { if (code == 200) Puts($"Heartbeat OK ({plugins.Count} plugin(s) reported)."); else PrintWarning($"Heartbeat failed (HTTP {code})."); }); } // Live server snapshot for the site dashboard: name, version, players, current map. private Dictionary CollectServerInfo() { var info = new Dictionary(); try { info["hostname"] = server.Name; info["port"] = server.Port; info["players"] = server.Players; info["max_players"] = server.MaxPlayers; info["map"] = new Dictionary { ["level_url"] = ConVar.Server.levelurl, ["seed"] = (int)global::World.Seed, ["size"] = (int)global::World.Size, }; } catch (Exception e) { PrintWarning("Could not collect server info: " + e.Message); } return info; } #endregion #region server.cfg writer private void WriteCfg(string levelUrl, int? seed) { try { string identity = ServerIdentity(); string cfgPath = Path.Combine("server", identity, "cfg", "server.cfg"); string dir = Path.GetDirectoryName(cfgPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); var lines = File.Exists(cfgPath) ? new List(File.ReadAllLines(cfgPath)) : new List(); if (levelUrl != null) { lines.RemoveAll(l => l.TrimStart().StartsWith("server.levelurl", StringComparison.OrdinalIgnoreCase)); if (levelUrl.Length > 0) lines.Add($"server.levelurl \"{levelUrl}\""); } if (seed.HasValue) { lines.RemoveAll(l => l.TrimStart().StartsWith("server.levelurl", StringComparison.OrdinalIgnoreCase)); lines.RemoveAll(l => l.TrimStart().StartsWith("server.seed", StringComparison.OrdinalIgnoreCase)); lines.Add($"server.seed {seed.Value}"); } File.WriteAllLines(cfgPath, lines); Puts($"Updated {cfgPath} (loads on next restart)."); } catch (Exception e) { PrintWarning("Could not write server.cfg: " + e.Message); } } #endregion #region Command [Command("bigkidscore")] private void CmdCore(IPlayer player, string command, string[] args) { bool isAdmin = player.IsServer || player.HasPermission("bigkidscore.admin"); string sub = args.Length > 0 ? args[0].ToLower() : "info"; switch (sub) { case "heartbeat": if (!isAdmin) { player.Reply("No permission."); return; } player.Reply("BigKidsCore: sending heartbeat…"); SendHeartbeat(); break; case "info": default: player.Reply($"BigKidsCore v{Version} (API v{CORE_API}). Ready: {IsReady()}. URL: {config.ApiBaseUrl}"); break; } } #endregion } }