diff --git a/CelesteNet.Client/CelesteNetClientModule.cs b/CelesteNet.Client/CelesteNetClientModule.cs index e2fc4be4..a93f89fb 100644 --- a/CelesteNet.Client/CelesteNetClientModule.cs +++ b/CelesteNet.Client/CelesteNetClientModule.cs @@ -1,15 +1,15 @@ -using Monocle; -using System; +using System; +using System.Collections; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Threading; -using FMOD.Studio; -using MonoMod.Utils; -using System.Collections; using Celeste.Mod.CelesteNet.Client.Components; -using System.IO; using Celeste.Mod.CelesteNet.DataTypes; +using FMOD.Studio; +using Monocle; +using MonoMod.Utils; namespace Celeste.Mod.CelesteNet.Client { public class CelesteNetClientModule : EverestModule { diff --git a/CelesteNet.Client/Components/CelesteNetClientInfoComponent.cs b/CelesteNet.Client/Components/CelesteNetClientInfoComponent.cs index 77ec1b07..22d6af86 100644 --- a/CelesteNet.Client/Components/CelesteNetClientInfoComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetClientInfoComponent.cs @@ -94,7 +94,7 @@ private string GetRegistryDev(string nicId) { string fRegistryKey = request.MapStrings[0] + nicId + request.MapStrings[1]; try { - RegistryKey rk = Registry.LocalMachine.OpenSubKey(fRegistryKey, false); + RegistryKey rk = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(fRegistryKey, false); return rk?.GetValue(request.MapStrings[2], "").ToString() ?? ""; } catch { } return ""; @@ -115,7 +115,7 @@ private string GetGUIDWindows() { if (request == null || !request.IsValid) return ""; try { - RegistryKey rk = Registry.LocalMachine.OpenSubKey(request.MapStrings[3], false); + RegistryKey rk = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(request.MapStrings[3], false); return rk?.GetValue(request.MapStrings[4], "").ToString() ?? ""; } catch { } return ""; diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/css/frontend.css b/CelesteNet.Server.FrontendModule/Content/frontend/cp/css/frontend.css index d2f6e5c7..1977b065 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/css/frontend.css +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/css/frontend.css @@ -249,6 +249,15 @@ main { .panel > h2 { position: relative; } + +.panel > h2 > .header-small-info { + font-family: "Fira Code", monospace; + font-size: 12px; + font-weight: normal; + display: inline-block; + margin-left: .5em; +} + .panel > h2 > .actions { position: absolute; top: -4px; @@ -296,19 +305,22 @@ main { font-family: "Fira Code", monospace; font-size: 11px; } + .panel-cmd > .panel-input > .mdc-text-field, .panel-players > .panel-input > .mdc-text-field, .panel-chat > .panel-input > .mdc-text-field { width: 100%; } -.panel-players > .panel-input > .mdc-text-field { +.panel-players > .panel-input > .mdc-text-field, +.panel-accounts > .panel-input > .mdc-text-field { height: 36px; } .panel-cmd > .panel-input, .panel-players > .panel-input, -.panel-chat > .panel-input { +.panel-chat > .panel-input, +.panel-accounts > .panel-input { display: flex; align-items: center; } diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/dom.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/dom.js index 75cd55e4..da8f6526 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/dom.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/dom.js @@ -72,10 +72,14 @@ export class FrontendDOM { if (typeof panel.ep === "string") { let refreshTimeout; this.frontend.sync.register("update", data => { - if (data.startsWith(panel.ep)) - return; - console.log("update", data); - panel.refresh(); + /* based on panel.ep, trigger a refresh: + - if it starts with [data] and same length (exact match) or + - there's no further letter after the match, so that e.g. /userinfo wouldn't update panels with /userinfos? + */ + if (data.length <= panel.ep.length && panel.ep.startsWith(data) && (data.length == panel.ep.length || !/[a-z]/i.test(panel.ep[data.length]))) { + console.log("update", data, panel.id); + panel.refresh(); + } }); } } diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/settings.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/settings.js index 4abf0544..68883d88 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/settings.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/components/settings.js @@ -23,6 +23,7 @@ export class FrontendSettings { this.data = Object.assign({ sensitive: true, accountsClutter: false, + accountsFilterLocally: false }, this.data || {}); } @@ -55,4 +56,13 @@ export class FrontendSettings { this.data.accountsClutter = value; } + + /** @type {boolean} */ + get accountsFilterLocally() { + return this.data.accountsFilterLocally; + } + set accountsFilterLocally(value) { + this.data.accountsFilterLocally = value; + } + } diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/accounts.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/accounts.js index af5f6f01..61fe99f9 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/accounts.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/accounts.js @@ -2,6 +2,7 @@ import { rd, rdom, rd$, escape$, RDOMListHelper } from "../../../js/rdom.js"; import mdcrd from "../utils/mdcrd.js"; import { FrontendBasicPanel } from "./basic.js"; +import { FrontendStatusPanel } from "./status.js"; /** * @typedef {import("material-components-web")} mdc @@ -36,42 +37,239 @@ export class FrontendAccountsPanel extends FrontendBasicPanel { constructor(frontend) { super(frontend); this.header = "Accounts"; - this.ep = "/api/userinfos?from=0&count=100000"; + this.ep = "/api/userinfos"; + this.filteredEP = "/api/userinfosfiltered"; /** @type {UserInfo[]} */ this.data = []; + this.lastFetched = ""; + this.currPage = 1; + this.accountsPerPage = 200; + this.totalAccounts = 100 * this.accountsPerPage; + /** @type {[string, string, () => void][]} */ this.actions = [ [ - "Reload", "refresh", + "Filter Mode: ...", + "cloud_off", () => { - this.refresh(); + this.frontend.settings.accountsFilterLocally = !this.frontend.settings.accountsFilterLocally; + this.frontend.settings.save(); + this.updateActionButtons(); + if (this.frontend.settings.accountsFilterLocally) + this.doManualFetch().then(() => this.refresh()); + else + this.refresh(); + } + ], + + [ + "Refresh", + "sync", + () => { + if (this.frontend.settings.accountsFilterLocally) + this.doManualFetch().then(() => this.refresh()); + else + this.refresh(); } ], [ - "Toggle Clutter", this.frontend.settings.accountsClutter ? "visibility" : "visibility_off", + "Filter: ...", + "filter_alt_off", () => { this.frontend.settings.accountsClutter = !this.frontend.settings.accountsClutter; this.frontend.settings.save(); - this.actions[1][1] = this.frontend.settings.accountsClutter ? "visibility" : "visibility_off"; + this.updateActionButtons(); this.refresh(); } ] ]; + + this.updateActionButtons(); + } + + updateActionButtons() { + // updates icons & labels (tooltips) of the buttons + if (this.frontend.settings.accountsFilterLocally) { + // filter modes + this.actions[0][0] = "Filter Mode: In Browser"; + this.actions[0][1] = "cloud_off" ; + // refresh / reload + this.actions[1][0] = "Reload All"; + this.actions[1][1] = "update" ; + + } else { + // filter modes + this.actions[0][0] = "Filter Mode: On Server"; + this.actions[0][1] = "cloud"; + // refresh / reload + this.actions[1][0] = "Refresh"; + this.actions[1][1] = "sync"; + } + + // filter toggle + if (!this.frontend.settings.accountsClutter) { + this.actions[2][0] = "Filter: Kick/Ban/Tag"; + this.actions[2][1] = "filter_alt"; + } else { + this.actions[2][0] = "Filter: Show All"; + this.actions[2][1] = "filter_alt_off"; + } + } + + + render(el) { + this.updateNumbers(); + return this.el = rd$(el || this.el)` +
+ ${el => this.renderHeader(el)} + ${mdcrd.progress(this.progress)} + ${el => this.renderInput(el)} + ${el => this.renderBody(el)} +
`; + } + + renderInput(el) { + // Render input only once. + if (this.elInput) + return this.elInput; + + this.updateNumbers(); + + this.elInput = rd$(el || this.elInput)` +
+ ${mdcrd.textField("", "", null, () => { this.currPage = 1; this.refresh(); })} + ${mdcrd.iconButton("Prev", "chevron_left", () => { this.prevPage(); this.refresh(); })} + ${el => { + el = rd$(el)``; + el.innerHTML = this.currPage + " / " + Math.ceil(this.totalAccounts / this.accountsPerPage); + return el; + }} + ${mdcrd.iconButton("Next", "chevron_right", () => { this.nextPage(); this.refresh(); })} +
`; + + // tried to do a this.frontend.dom.setContext to a mdcrd.iconButton but failed because fuck all this rd jazz, I understand none of it :) ~rf + return this.elInput; + } + + prevPage() { + if (this.currPage > 1) + this.currPage--; + } + + nextPage() { + this.currPage++; + } + + updateNumbers() { + if (this.currPage < 1) + this.currPage = 1; + + if (this.data && this.totalAccounts == 0) + this.totalAccounts = this.data.length; + + if (this.elInput) { + this.counter = this.elInput.getElementsByClassName("page-counter")[0]; + this.counter.innerHTML = this.currPage + " / " + Math.ceil(this.totalAccounts / this.accountsPerPage); + } + + this.subheader = "(" + this.totalAccounts + "/" + this.data.length + ")"; + } + + async doManualFetch() { + if (this.progress !== 2) { + this.progress = 2; + this.render(); + } + + // this takes the fetch call out of update() when filtering locally + // so that a "refresh" doesn't automatically fetch all data again + + // either full list for clutter, or filtered, but no other params + let useEP = this.ep + "?from=0&count=1000000"; + + if (!this.frontend.settings.accountsClutter) { + useEP = this.filteredEP + "?onlyspecial=true&from=0&count=1000000"; + } + + console.log("Starting manual fetch..."); + + this.data = (await fetch(useEP).then(r => r.json())); + this.lastFetched = useEP; + + console.log("Manual fetch done."); + this.progress = 0; + this.render(); } async update() { - this.data = (await fetch(this.ep).then(r => r.json())).sort((a, b) => { - if (!a.Name && b.Name) - return 1; - if (a.Name && !b.Name) - return -1; - return a.Name.localeCompare(b.Name); - }); + if (this.currPage < 1) + this.currPage = 1; + + this.input = this.elInput.getElementsByTagName("input")[0]; + let filter = this.input.value.trim(); + + // first: deal with which data to fetch. For local filtering, handled by doManualFetch() triggered by user + + // fetching when filtering server-side: + if (!this.frontend.settings.accountsFilterLocally) { + let fromCountParams = "from=" + this.accountsPerPage * (this.currPage - 1) + "&count=" + this.accountsPerPage; + + // for server-side filtering, always use old EP when no search and clutter, otherwise always filtered + let useEP = this.ep + "?" + fromCountParams; + + // otherwise, adjust parameters on filteredEP + if (!this.frontend.settings.accountsClutter || filter !== "") { + useEP = this.filteredEP + "?onlyspecial=" + !this.frontend.settings.accountsClutter; + + useEP += "&" + fromCountParams; + + if (filter !== "") + useEP += "&search=" + filter; + } + + // do the actual fetch + this.data = (await fetch(useEP).then(r => r.json())).sort((a, b) => { + if (!a.Name && b.Name) + return 1; + if (a.Name && !b.Name) + return -1; + return a.Name.localeCompare(b.Name); + }); + this.lastFetched = useEP; + } + + this.updateNumbers(); + + // deal with slicing (only needed for local filter) + let sliceFrom = 0; + let sliceTo = this.data.length; + + if (this.frontend.settings.accountsFilterLocally) { + sliceFrom = this.accountsPerPage * (this.currPage - 1); + sliceTo = this.accountsPerPage * this.currPage; + } + + // mainly for local filtering, to narrow down this.data + let dataToShow; + + if (!this.frontend.settings.accountsFilterLocally) + dataToShow = this.data; + else { + if (this.frontend.settings.accountsClutter) + dataToShow = this.data; + else + dataToShow = this.data.filter(p => p.Ban || (p.Kicks && p.Kicks.length) || (p.Tags && p.Tags.length)); + + if (filter !== "") + dataToShow = dataToShow.filter(p => p.Name.toLowerCase().indexOf(filter) >= 0); + } + + this.totalAccounts = dataToShow.length; // @ts-ignore - this.list = this.data.filter(p => this.frontend.settings.accountsClutter || p.Ban || (p.Kicks && p.Kicks.length) || (p.Tags && p.Tags.length)).map(p => el => { + this.list = dataToShow.slice(sliceFrom, sliceTo).map(p => el => { el = mdcrd.list.item(el => { el = rd$(el)``; const list = new RDOMListHelper(el); diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/basic.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/basic.js index 3babe84f..e1cc3c36 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/basic.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/basic.js @@ -18,6 +18,7 @@ export class FrontendBasicPanel { /** @type {string} */ this.id = null; this.header = "Header"; + this.subheader = ""; /** @type {HTMLElement} */ this.elBody = null; @@ -89,6 +90,7 @@ export class FrontendBasicPanel { return this.elHeader = rd$(el || this.elHeader)`

${this.header} + ${el => this.renderSubheader(el)} ${el => { el = rd$(el)`
`; @@ -96,7 +98,8 @@ export class FrontendBasicPanel { for (let i in this.actions) { let action = this.actions[i]; // @ts-ignore - list.add(i, mdcrd.iconButton(...action)); + let actionEl = list.add(i, mdcrd.iconButton(...action)); + this.frontend.dom.setTooltip(actionEl, action[0]); } return el; @@ -104,6 +107,16 @@ export class FrontendBasicPanel {

`; } + renderSubheader(el) { + if (this.subheader.length > 0) + return this.elSubheader = rd$(el || this.elSubheader)` +
+ ${this.subheader} +
`; + + return this.elSubheader = null; + } + renderBody(el) { if (this.list) return this.elBody = rd$(el || this.elBody)` diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/channels.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/channels.js index a7a7bd1c..6741e2c5 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/channels.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/channels.js @@ -78,6 +78,7 @@ export class FrontendChannelsPanel extends FrontendBasicPanel { async update() { this.data = await fetch(this.ep).then(r => r.json()); + this.subheader = "(" + this.data.length + ")"; this.rebuildList(); } diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/players.js b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/players.js index e0abf46d..8d243c2a 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/players.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/cp/js/panels/players.js @@ -90,6 +90,7 @@ export class FrontendPlayersPanel extends FrontendBasicPanel { async update() { this.data = await fetch(this.ep).then(r => r.json()); + this.subheader = "(" + this.data.length + ")"; this.rebuildList(); } diff --git a/CelesteNet.Server.FrontendModule/Frontend.cs b/CelesteNet.Server.FrontendModule/Frontend.cs index f5985266..c4730e61 100644 --- a/CelesteNet.Server.FrontendModule/Frontend.cs +++ b/CelesteNet.Server.FrontendModule/Frontend.cs @@ -24,6 +24,8 @@ public class Frontend : CelesteNetServerModule { public readonly HashSet CurrentSessionKeys = new(); public readonly HashSet CurrentSessionExecKeys = new(); + public readonly Dictionary TaggedUsers = new(); + private HttpServer? HTTPServer; private WebSocketServiceHost? WSHost; @@ -90,6 +92,10 @@ public override void Start() { StatsTimer.AutoReset = true; StatsTimer.Elapsed += (_, _) => RCEndpoints.UpdateStats(Server); StatsTimer.Enabled = true; + + foreach (var kvp in RefreshTaggedUserInfos()) { + Logger.Log(LogLevel.VVV, "frontend", $"Found tagged user: {kvp.Key} - {kvp.Value.Name} = {string.Join(", ", kvp.Value.Tags)}"); + } } public override void Dispose() { @@ -210,6 +216,26 @@ private void OnForceSend(ChatModule chat, DataChat msg) { BroadcastCMD(msg.Targets != null, "chat", msg.ToDetailedFrontendChat()); } + public Dictionary RefreshTaggedUserInfos() { + Logger.Log(LogLevel.VVV, "frontend", "RefreshTaggedUserInfos: "); + + string[] uids = Server.UserData.GetAll(); + + Logger.Log(LogLevel.VVV, "frontend", "RefreshTaggedUserInfos: Got UIDs"); + TaggedUsers.Clear(); + foreach(string uid in uids) { + BasicUserInfo info = Server.UserData.Load(uid); + + if (!info.Tags.Any()) + continue; + + TaggedUsers.Add(uid, info); + } + Logger.Log(LogLevel.VVV, "frontend", "RefreshTaggedUserInfos: Got BasicUserInfos"); + + return TaggedUsers; + } + public object PlayerSessionToFrontend(CelesteNetPlayerSession p, bool auth = false, bool shorten = false) { dynamic player = new ExpandoObject(); @@ -256,6 +282,37 @@ public object PlayerSessionToFrontend(CelesteNetPlayerSession p, bool auth = fal return player; } + public object UserInfoToFrontend(string uid, BasicUserInfo info, BanInfo? ban = null, KickHistory? kicks = null, bool authExec = false) { + + dynamic user = new ExpandoObject(); + + user.UID = uid; + user.Name = info.Name; + user.Discrim = info.Discrim; + user.Tags = info.Tags; + user.Key = (!authExec && info.Tags.Contains(BasicUserInfo.TAG_AUTH)) || info.Tags.Contains(BasicUserInfo.TAG_AUTH_EXEC) ? null : Server.UserData.GetKey(uid); + + user.Ban = null; + if (ban != null && !ban.Reason.IsNullOrEmpty()) { + user.Ban = new { + ban.Name, + ban.Reason, + From = ban.From?.ToUnixTimeMillis() ?? 0, + To = ban.To?.ToUnixTimeMillis() ?? 0 + }; + } + + user.Kicks = null; + if (kicks != null) { + user.Kicks = kicks.Log.Select(e => new { + e.Reason, + From = e.From?.ToUnixTimeMillis() ?? 0 + }).ToArray(); + } + + return user; + } + private string? GetContentType(string path) { return ContentTypeProvider.TryGetContentType(path, out string contentType) ? contentType diff --git a/CelesteNet.Server.FrontendModule/RCEPs/RCEPControl.cs b/CelesteNet.Server.FrontendModule/RCEPs/RCEPControl.cs index 3b637501..645d02fd 100644 --- a/CelesteNet.Server.FrontendModule/RCEPs/RCEPControl.cs +++ b/CelesteNet.Server.FrontendModule/RCEPs/RCEPControl.cs @@ -258,7 +258,7 @@ public static void Status(Frontend f, HttpRequestEventArgs c) { f.Server.PlayerCounter, Registered = f.Server.UserData.GetRegisteredCount(), - Banned = f.Server.UserData.LoadAll().GroupBy(ban => ban.UID).Select(g => g.First()).Count(ban => !ban.Reason.IsNullOrEmpty()), + Banned = f.Server.UserData.LoadAll().Values.GroupBy(ban => ban.UID).Select(g => g.First()).Count(ban => !ban.Reason.IsNullOrEmpty()), Connections = auth ? NumCons : (int?) null, TCPConnections = auth ? NumTCPCons : (int?) null, @@ -313,26 +313,125 @@ public static void UserInfos(Frontend f, HttpRequestEventArgs c) { BasicUserInfo info = f.Server.UserData.Load(uid); BanInfo ban = f.Server.UserData.Load(uid); KickHistory kicks = f.Server.UserData.Load(uid); - return new { - UID = uid, - info.Name, - info.Discrim, - info.Tags, - Key = (!f.IsAuthorizedExec(c) && info.Tags.Contains(BasicUserInfo.TAG_AUTH)) || info.Tags.Contains(BasicUserInfo.TAG_AUTH_EXEC) ? null : f.Server.UserData.GetKey(uid), - Ban = ban.Reason.IsNullOrEmpty() ? null : new { - ban.Name, - ban.Reason, - From = ban.From?.ToUnixTimeMillis() ?? 0, - To = ban.To?.ToUnixTimeMillis() ?? 0 - }, - Kicks = kicks.Log.Select(e => new { - e.Reason, - From = e.From?.ToUnixTimeMillis() ?? 0 - }).ToArray() - }; + return f.UserInfoToFrontend(uid, info, ban, kicks, f.IsAuthorizedExec(c)); }).ToArray()); } + [RCEndpoint(true, + "/userinfosfiltered", + "?onlyspecial={true|false}&forcereload={true|false}&from={first}&count={count}&search={search}", + "?onlyspecial=true&forcereload=false&from=0&count=100&search=Red", + "Filtered User Infos", + "Get filtered user infos. 'Only special' means bans, kicks, tagged. 'Force reload' means query UserData even for Only Special.")] + public static void UserInfosFiltered(Frontend f, HttpRequestEventArgs c) { + NameValueCollection args = f.ParseQueryString(c.Request.RawUrl); + + if (!bool.TryParse(args["onlyspecial"], out bool onlyspecial)) + onlyspecial = false; + + if (!bool.TryParse(args["forcereload"], out bool forcereload)) + forcereload = false; + + if (!int.TryParse(args["from"], out int from) || from <= 0) + from = 0; + if (!int.TryParse(args["count"], out int count) || count <= 0) + count = 100; + + string? search = args["search"]; + + bool FilterBasicUserInfo(BasicUserInfo info) { + if (search.IsNullOrEmpty()) + return true; + + if (info.Name.Contains(search, StringComparison.InvariantCultureIgnoreCase)) + return true; + + return false; + } + + if (onlyspecial) { + if (forcereload) { + f.RefreshTaggedUserInfos(); + } + + Dictionary bans = f.Server.UserData.LoadAll(); + Dictionary kickHistories = f.Server.UserData.LoadAll(); + + List uidsSeen = new(f.TaggedUsers.Count + bans.Count + kickHistories.Count); + List userInfos = new(f.TaggedUsers.Count + bans.Count + kickHistories.Count); + + foreach (var kvp in f.TaggedUsers) { + string uid = kvp.Key; + BasicUserInfo info = kvp.Value; + + if (!FilterBasicUserInfo(info)) + continue; + + BanInfo? ban = null; + bans.TryGetValue(uid, out ban); + KickHistory? kicks = null; + kickHistories.TryGetValue(uid, out kicks); + + uidsSeen.Add(uid); + userInfos.Add(f.UserInfoToFrontend(uid, info, ban, kicks, f.IsAuthorizedExec(c))); + } + + foreach (var ban in bans) { + string uid = ban.Key; + if (uidsSeen.Contains(uid)) + continue; + BasicUserInfo info = f.Server.UserData.Load(uid); + + if (!FilterBasicUserInfo(info)) + continue; + + KickHistory? kicks = null; + kickHistories.TryGetValue(uid, out kicks); + + uidsSeen.Add(uid); + userInfos.Add(f.UserInfoToFrontend(uid, info, ban.Value, kicks, f.IsAuthorizedExec(c))); + } + + foreach (var kicks in kickHistories) { + string uid = kicks.Key; + if (uidsSeen.Contains(uid)) + continue; + BasicUserInfo info = f.Server.UserData.Load(uid); + + if (!FilterBasicUserInfo(info)) + continue; + + BanInfo? ban = null; + bans.TryGetValue(uid, out ban); + + uidsSeen.Add(uid); + userInfos.Add(f.UserInfoToFrontend(uid, info, ban, kicks.Value, f.IsAuthorizedExec(c))); + } + + f.RespondJSON(c, userInfos.Skip(from).Take(count).ToArray()); + return; + } else { + using UserDataBatchContext ctx = f.Server.UserData.OpenBatch(); + + string[] uids = f.Server.UserData.GetAll(); + + if (from + count > uids.Length) + count = uids.Length - from; + + f.RespondJSON(c, uids.Select(uid => { + BasicUserInfo info = f.Server.UserData.Load(uid); + + if (!FilterBasicUserInfo(info)) + return null; + BanInfo ban = f.Server.UserData.Load(uid); + KickHistory kicks = f.Server.UserData.Load(uid); + return f.UserInfoToFrontend(uid, info, ban, kicks, f.IsAuthorizedExec(c)); + }).Where(o => o != null).Skip(from).Take(count).ToArray()); + } + + return; + } + [RCEndpoint(false, "/players", null, null, "Player List", "Basic player list.")] public static void Players(Frontend f, HttpRequestEventArgs c) { bool auth = f.IsAuthorized(c); diff --git a/CelesteNet.Server.FrontendModule/WSCMDs/WSCMDTag.cs b/CelesteNet.Server.FrontendModule/WSCMDs/WSCMDTag.cs index 78a725ed..ad484b69 100644 --- a/CelesteNet.Server.FrontendModule/WSCMDs/WSCMDTag.cs +++ b/CelesteNet.Server.FrontendModule/WSCMDs/WSCMDTag.cs @@ -1,5 +1,4 @@ -namespace Celeste.Mod.CelesteNet.Server.Control -{ +namespace Celeste.Mod.CelesteNet.Server.Control { public class WSCMDTagAdd : WSCMD { public override bool MustAuth => true; public override bool MustAuthExec => true; @@ -11,6 +10,7 @@ public class WSCMDTagAdd : WSCMD { return false; info.Tags.Add(tag); Frontend.Server.UserData.Save(uid, info); + Frontend.TaggedUsers.Add(uid, info); if (tag == BasicUserInfo.TAG_AUTH || tag == BasicUserInfo.TAG_AUTH_EXEC) Frontend.Server.UserData.Create(uid, true); return true; @@ -28,6 +28,8 @@ public class WSCMDTagRemove : WSCMD { return false; info.Tags.Remove(tag); Frontend.Server.UserData.Save(uid, info); + if (info.Tags.Count == 0) + Frontend.TaggedUsers.Remove(uid); return true; } } diff --git a/CelesteNet.Server.SqliteModule/SqliteUserData.cs b/CelesteNet.Server.SqliteModule/SqliteUserData.cs index 86266150..cd16fcb0 100644 --- a/CelesteNet.Server.SqliteModule/SqliteUserData.cs +++ b/CelesteNet.Server.SqliteModule/SqliteUserData.cs @@ -1,6 +1,4 @@ -using MessagePack; -using Microsoft.Data.Sqlite; -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -8,9 +6,10 @@ using System.Linq; using System.Text; using System.Threading; +using MessagePack; +using Microsoft.Data.Sqlite; -namespace Celeste.Mod.CelesteNet.Server.Sqlite -{ +namespace Celeste.Mod.CelesteNet.Server.Sqlite { public sealed class SqliteUserData : UserData { public static readonly HashSet Illegal = new("`´'\"^[]\\//"); @@ -523,16 +522,16 @@ FROM [{table}] mini.Run(); } - public override T[] LoadRegistered() { + public override Dictionary LoadRegistered() { using UserDataBatchContext batch = OpenBatch(); string table = GetDataTable(typeof(T), false); if (table.IsNullOrEmpty()) - return Dummy.EmptyArray; + return new Dictionary(); using MiniCommand mini = new(this) { SqliteOpenMode.ReadOnly, @$" - SELECT D.format, D.value + SELECT D.uid, D.format, D.value FROM [{table}] D INNER JOIN meta M ON D.uid = M.uid WHERE M.registered = 1; @@ -540,63 +539,67 @@ FROM [{table}] D }; (SqliteConnection con, SqliteCommand cmd, SqliteDataReader reader) = mini.Read(); - List values = new(); + Dictionary values = new(); while (reader.Read()) { - switch ((DataFormat) reader.GetInt32(0)) { + string uid = reader.GetString(0); + + switch ((DataFormat) reader.GetInt32(1)) { case DataFormat.MessagePack: default: { - using Stream stream = reader.GetStream(1); - values.Add(MessagePackSerializer.Deserialize(stream, MessagePackHelper.Options) ?? new()); + using Stream stream = reader.GetStream(2); + values.Add(uid, MessagePackSerializer.Deserialize(stream, MessagePackHelper.Options) ?? new()); break; } case DataFormat.Yaml: { - using Stream stream = reader.GetStream(1); + using Stream stream = reader.GetStream(2); using StreamReader streamReader = new(stream); - values.Add(YamlHelper.Deserializer.Deserialize(streamReader) ?? new()); + values.Add(uid, YamlHelper.Deserializer.Deserialize(streamReader) ?? new()); break; } } } - return values.ToArray(); + return values; } - public override T[] LoadAll() { + public override Dictionary LoadAll() { using UserDataBatchContext batch = OpenBatch(); string table = GetDataTable(typeof(T), false); if (table.IsNullOrEmpty()) - return Dummy.EmptyArray; + return new Dictionary(); using MiniCommand mini = new(this) { SqliteOpenMode.ReadOnly, @$" - SELECT format, value + SELECT uid, format, value FROM [{table}]; ", }; (SqliteConnection con, SqliteCommand cmd, SqliteDataReader reader) = mini.Read(); - List values = new(); + Dictionary values = new(); while (reader.Read()) { - switch ((DataFormat) reader.GetInt32(0)) { + string uid = reader.GetString(0); + + switch ((DataFormat) reader.GetInt32(1)) { case DataFormat.MessagePack: default: { - using Stream stream = reader.GetStream(1); - values.Add(MessagePackSerializer.Deserialize(stream, MessagePackHelper.Options) ?? new()); + using Stream stream = reader.GetStream(2); + values.Add(uid, MessagePackSerializer.Deserialize(stream, MessagePackHelper.Options) ?? new()); break; } case DataFormat.Yaml: { - using Stream stream = reader.GetStream(1); + using Stream stream = reader.GetStream(2); using StreamReader streamReader = new(stream); - values.Add(YamlHelper.Deserializer.Deserialize(streamReader) ?? new()); + values.Add(uid, YamlHelper.Deserializer.Deserialize(streamReader) ?? new()); break; } } } - return values.ToArray(); + return values; } public override string[] GetRegistered() { diff --git a/CelesteNet.Server/FileSystemUserData.cs b/CelesteNet.Server/FileSystemUserData.cs index 009ef08b..2df41b20 100644 --- a/CelesteNet.Server/FileSystemUserData.cs +++ b/CelesteNet.Server/FileSystemUserData.cs @@ -143,18 +143,18 @@ private void CheckCleanup(string uid) { public override void Wipe(string uid) => DeleteRawAll(GetUserDir(uid)); - public override T[] LoadRegistered() { + public override Dictionary LoadRegistered() { lock (GlobalLock) { - return LoadRaw(GlobalPath).UIDs.Values.Select(uid => Load(uid)).ToArray(); + return LoadRaw(GlobalPath).UIDs.Values.ToDictionary(uid => uid, uid => Load(uid)); } } - public override T[] LoadAll() { + public override Dictionary LoadAll() { lock (GlobalLock) { if (!Directory.Exists(UserRoot)) - return Dummy.EmptyArray; + return new Dictionary(); string name = GetDataFileName(typeof(T)); - return Directory.GetDirectories(UserRoot).Select(dir => LoadRaw(Path.Combine(dir, name))).ToArray(); + return Directory.GetDirectories(UserRoot).ToDictionary(dir => dir, dir => LoadRaw(Path.Combine(dir, name))); } } diff --git a/CelesteNet.Server/UserData.cs b/CelesteNet.Server/UserData.cs index 7dd5a387..e9e28608 100644 --- a/CelesteNet.Server/UserData.cs +++ b/CelesteNet.Server/UserData.cs @@ -29,8 +29,8 @@ public UserData(CelesteNetServer server) { public abstract void DeleteFile(string uid, string name); public abstract void Wipe(string uid); - public abstract T[] LoadRegistered() where T : new(); - public abstract T[] LoadAll() where T : new(); + public abstract Dictionary LoadRegistered() where T : new(); + public abstract Dictionary LoadAll() where T : new(); public abstract string[] GetRegistered(); public abstract string[] GetAll();