using System.Reflection; using System.Text; using LiteDB; using Microsoft.AspNetCore.Mvc; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes.db_class; namespace Campus_live_rewrite.Controllers { // --------------------------------------------------------------------------------- // Admin panel "Database" tab: browse, search, edit, add and delete documents in the server's // LiteDB databases (Data/DBs/*.db). // // Every database is reached through the LiteDatabase the server ALREADY holds open (read from // the owning class's static field) - LiteDB locks its file, so opening a second handle would // fail or fight the live one. Links.db is the exception: LinkDB opens it per call, so it is // opened per request here too. // // Safety: // * dev token + admin session + admin IP allowlist, like every /admin/api route; // * secrets (password, tokens, API keys, hashes) are shown masked and put back on save, so // editing a document never overwrites them with the mask; // * every save and delete is appended to Data/db-editor-audit.jsonl with the document as it // was before and after - the undo trail; // * no dropping collections or databases, and a document's _id can't be changed. // --------------------------------------------------------------------------------- [ApiController] public class AdminDatabaseController : ControllerBase { private sealed record Entry(string Name, Type? Owner, string? Field, string? Path, string About); private static readonly Entry[] Dbs = { new("Players", typeof(PlayerDB), "PlayerDBFile", null, "accounts, profiles, balances, bans"), new("Rooms", typeof(RoomDB), "rooms_db", null, "rooms and subrooms"), new("PlayersFriends", typeof(FriendsDB), "PlayerFriendDBFile", null, "friends, blocks, favourites"), new("Heartbeats", typeof(HeartbeatDB), "_db", null, "presence (who is where)"), new("Images", typeof(ImageMetadataDB), "_dbInstance", null, "photos, cheers, comments"), new("Chat", typeof(ChatDB), "ChatDBFile", null, "chat threads and messages"), new("Events", typeof(EventDB), "EventDBFile", null, "player events"), new("Clubs", typeof(ClubDB), "_db", null, "clubs"), new("Inventions", typeof(InventionDB), "_db", null, "inventions"), new("Settings", typeof(SettingsDB), "_db", null, "player settings"), new("Leaderboard", typeof(LeaderboardDB), "DB", null, "leaderboards"), new("Support", typeof(SupportDB), "DB", null, "support tickets"), new("Reports", typeof(ReportsDB), "_db", null, "player reports"), new("ModerationLog", typeof(ModerationLogDB), "_db", null, "ban logs"), new("BugReports", typeof(BugReportDB), "_db", null, "bug reports"), new("CuratedLists", typeof(CuratedListDB), "_db", null, "curated room lists"), new("Earnings", typeof(EarningsDB), "_db", null, "creator earnings"), new("RoomEcon", typeof(RoomEconDB), "_db", null, "room currencies, keys, offers"), new("Wishlists", typeof(WishlistDB), "_db", null, "wishlists"), new("SitePrefs", typeof(SitePrefsDB), "_db", null, "website preferences"), new("RefreshTokens", typeof(RefreshTokenDB), "_db", null, "sign-in refresh tokens"), new("ProgressionEvents", typeof(ProgressionEvents), "_db", null, "limited-time challenge progress"), new("RewardSelections", typeof(RewardSelections), "_db", null, "reward card picks"), new("Socials", typeof(SocialLinks), "_db", null, "linked social accounts"), new("Spotify", typeof(SpotifyLink), "_db", null, "linked Spotify accounts"), new("Links", null, null, "Data/DBs/Links.db", "links"), }; private const string Mask = "(hidden)"; private static readonly HashSet Secret = new(StringComparer.OrdinalIgnoreCase) { "password", "passwordhash", "hashedpassword", "salt", "hash", "tokenhash", "secret", "clientsecret", "accesstoken", "refreshtoken", "authtoken", "apikey", }; private static string AuditPath => System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "db-editor-audit.jsonl"); private static readonly object _auditLock = new(); // The live handle for a database; `owned` is non-null when this request opened it itself. private static LiteDatabase? Open(Entry e, out IDisposable? owned) { owned = null; if (e.Path != null) { var db = new LiteDatabase(e.Path); owned = db; return db; } var fi = e.Owner!.GetField(e.Field!, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); return fi?.GetValue(null) switch { LiteDatabase d => d, Lazy l => l.Value, _ => null }; } private static Entry? Find(string name) => Dbs.FirstOrDefault(d => d.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); private static ContentResult J(object o, int status = 200) => new() { Content = Newtonsoft.Json.JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status }; private static string Pretty(BsonValue v) { var sw = new StringWriter(); new JsonWriter(sw) { Pretty = true, Indent = 2 }.Serialize(v); return sw.ToString(); } private static BsonValue MaskSecrets(BsonValue v) { if (v.IsDocument) { var d = new BsonDocument(); foreach (var kv in v.AsDocument) d[kv.Key] = Secret.Contains(kv.Key) && !kv.Value.IsNull ? Mask : MaskSecrets(kv.Value); return d; } if (v.IsArray) return new BsonArray(v.AsArray.Select(MaskSecrets)); return v; } // Put the real value back wherever the edited document still carries the mask. private static void Unmask(BsonValue edited, BsonValue? original) { if (edited.IsDocument && original != null && original.IsDocument) { var e = edited.AsDocument; var o = original.AsDocument; foreach (var key in e.Keys.ToList()) { if (Secret.Contains(key) && e[key].IsString && e[key].AsString == Mask) e[key] = o.ContainsKey(key) ? o[key] : BsonValue.Null; else if (o.ContainsKey(key)) Unmask(e[key], o[key]); } } else if (edited.IsArray && original != null && original.IsArray) { var e = edited.AsArray; var o = original.AsArray; for (int i = 0; i < e.Count && i < o.Count; i++) Unmask(e[i], o[i]); } } private static void Audit(player_info admin, string db, string col, string op, BsonValue id, BsonValue? before, BsonValue? after) { var line = new BsonDocument { ["at"] = DateTime.UtcNow.ToString("o"), ["admin"] = (long)admin.Id, ["adminName"] = admin.Username ?? "", ["db"] = db, ["collection"] = col, ["op"] = op, ["id"] = id, ["before"] = before ?? BsonValue.Null, ["after"] = after ?? BsonValue.Null }; lock (_auditLock) System.IO.File.AppendAllText(AuditPath, LiteDB.JsonSerializer.Serialize(line) + "\n"); } private static BsonValue ParseId(string? raw) { raw = (raw ?? "").Trim(); if (raw.Length == 0) throw new ArgumentException("No document id."); return LiteDB.JsonSerializer.Deserialize(raw); } // ---- list --------------------------------------------------------------------------- [HttpGet("/admin/api/db/list")] public ContentResult List() { var (_, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var outList = new List(); foreach (var e in Dbs) { string file = System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "DBs", e.Name + ".db"); long size = System.IO.File.Exists(file) ? new FileInfo(file).Length : 0; try { var db = Open(e, out var owned); using (owned) { if (db == null) { outList.Add(new { name = e.Name, about = e.About, size, error = "not reachable" }); continue; } var cols = db.GetCollectionNames().Where(c => !c.StartsWith("$")).OrderBy(c => c) .Select(c => new { name = c, count = db.GetCollection(c).LongCount() }).ToList(); outList.Add(new { name = e.Name, about = e.About, size, collections = cols }); } } catch (Exception ex) { outList.Add(new { name = e.Name, about = e.About, size, error = ex.Message }); } } return J(new { success = true, databases = outList }); } // ---- browse / search ---------------------------------------------------------------- [HttpGet("/admin/api/db/{db}/{col}")] public ContentResult Browse(string db, string col, [FromQuery] int skip = 0, [FromQuery] int limit = 50, [FromQuery] string? q = null, [FromQuery] string? where = null) { var (_, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = Find(db); if (e == null) return J(new { success = false, message = "No such database." }); skip = Math.Max(0, skip); limit = Math.Clamp(limit, 1, 200); try { var ldb = Open(e, out var owned); using (owned) { if (ldb == null || !ldb.CollectionExists(col)) return J(new { success = false, message = "No such collection." }); var c = ldb.GetCollection(col); List page; long total; bool capped = false; if (!string.IsNullOrWhiteSpace(where)) { var expr = BsonExpression.Create(where); total = c.LongCount(expr); page = c.Find(expr, skip, limit).ToList(); } else if (!string.IsNullOrWhiteSpace(q)) { // Text search over each document's JSON (secrets excluded), capped so a huge // collection can't stall the server. const int scanCap = 50000; var hits = new List(); int scanned = 0; foreach (var d in c.FindAll()) { if (++scanned > scanCap) { capped = true; break; } if (LiteDB.JsonSerializer.Serialize(MaskSecrets(d)).Contains(q, StringComparison.OrdinalIgnoreCase)) hits.Add(d); } total = hits.Count; page = hits.Skip(skip).Take(limit).ToList(); } else { total = c.LongCount(); page = c.Find(Query.All(), skip, limit).ToList(); } var rows = page.Select(d => { string json = LiteDB.JsonSerializer.Serialize(MaskSecrets(d)); return new { id = LiteDB.JsonSerializer.Serialize(d["_id"]), preview = json.Length > 240 ? json.Substring(0, 240) + "…" : json }; }).ToList(); return J(new { success = true, total, skip, limit, capped, rows }); } } catch (Exception ex) { return J(new { success = false, message = ex.Message }); } } [HttpGet("/admin/api/db/{db}/{col}/doc")] public ContentResult GetDoc(string db, string col, [FromQuery] string? id) { var (_, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = Find(db); if (e == null) return J(new { success = false, message = "No such database." }); try { var ldb = Open(e, out var owned); using (owned) { if (ldb == null || !ldb.CollectionExists(col)) return J(new { success = false, message = "No such collection." }); var d = ldb.GetCollection(col).FindById(ParseId(id)); if (d == null) return J(new { success = false, message = "That document no longer exists." }); return J(new { success = true, id = LiteDB.JsonSerializer.Serialize(d["_id"]), json = Pretty(MaskSecrets(d)) }); } } catch (Exception ex) { return J(new { success = false, message = ex.Message }); } } // ---- write -------------------------------------------------------------------------- public sealed class SaveBody { public string? id { get; set; } public string? json { get; set; } } [HttpPost("/admin/api/db/{db}/{col}/save")] public ContentResult Save(string db, string col, [FromBody] SaveBody body) { var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = Find(db); if (e == null) return J(new { success = false, message = "No such database." }); BsonDocument doc; try { var v = LiteDB.JsonSerializer.Deserialize(body?.json ?? ""); if (!v.IsDocument) return J(new { success = false, message = "The JSON must be one object { ... }." }); doc = v.AsDocument; } catch (Exception ex) { return J(new { success = false, message = "That isn't valid JSON: " + ex.Message }); } try { var ldb = Open(e, out var owned); using (owned) { if (ldb == null || !ldb.CollectionExists(col)) return J(new { success = false, message = "No such collection." }); var c = ldb.GetCollection(col); bool isNew = string.IsNullOrWhiteSpace(body!.id); BsonDocument? before = null; if (!isNew) { var id = ParseId(body.id); before = c.FindById(id); if (before == null) return J(new { success = false, message = "That document no longer exists - reload." }); if (!doc.ContainsKey("_id")) doc["_id"] = id; if (doc["_id"] != id) return J(new { success = false, message = "The _id can't be changed. Add a new document and delete this one instead." }); Unmask(doc, before); c.Update(doc); } else { if (doc.ContainsKey("_id") && c.FindById(doc["_id"]) != null) return J(new { success = false, message = "A document with that _id already exists." }); var newId = c.Insert(doc); doc["_id"] = newId; } Audit(admin!, e.Name, col, isNew ? "insert" : "update", doc["_id"], before, doc); Console.WriteLine($"[db-editor] {admin!.Username} ({admin.Id}) {(isNew ? "added" : "saved")} {e.Name}.{col} {LiteDB.JsonSerializer.Serialize(doc["_id"])}"); return J(new { success = true, id = LiteDB.JsonSerializer.Serialize(doc["_id"]), message = isNew ? "Added." : "Saved." }); } } catch (Exception ex) { return J(new { success = false, message = ex.Message }); } } public sealed class DeleteBody { public string? id { get; set; } } [HttpPost("/admin/api/db/{db}/{col}/delete")] public ContentResult Delete(string db, string col, [FromBody] DeleteBody body) { var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var e = Find(db); if (e == null) return J(new { success = false, message = "No such database." }); try { var ldb = Open(e, out var owned); using (owned) { if (ldb == null || !ldb.CollectionExists(col)) return J(new { success = false, message = "No such collection." }); var c = ldb.GetCollection(col); var id = ParseId(body?.id); var before = c.FindById(id); if (before == null) return J(new { success = false, message = "That document no longer exists." }); c.Delete(id); Audit(admin!, e.Name, col, "delete", id, before, null); Console.WriteLine($"[db-editor] {admin!.Username} ({admin.Id}) deleted {e.Name}.{col} {LiteDB.JsonSerializer.Serialize(id)}"); return J(new { success = true, message = "Deleted." }); } } catch (Exception ex) { return J(new { success = false, message = ex.Message }); } } } }