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)`
+ `;
+
+ 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