From ba84e78d614be2c9f31c7ca244fd04c38a7508d4 Mon Sep 17 00:00:00 2001 From: ChrisChrome Date: Fri, 3 Jul 2026 15:56:22 -0600 Subject: [PATCH 1/2] username stuff --- lib/roblox.js | 147 ++++++++++++++++++++++++++++++++++++ public/css/style.css | 34 +++++++++ public/dashboard/index.html | 12 ++- public/js/dashboard.js | 119 ++++++++++++++++++++++++++--- routes/api/v1/ingame.js | 31 +++++--- routes/dashboard.js | 31 ++++++++ 6 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 lib/roblox.js diff --git a/lib/roblox.js b/lib/roblox.js new file mode 100644 index 0000000..5d4cf17 --- /dev/null +++ b/lib/roblox.js @@ -0,0 +1,147 @@ +// Thin wrapper around Roblox's public Users API for resolving usernames <-> user ids. These +// endpoints require no authentication, so we just use the runtime's built-in fetch() - same +// pattern as lib/webhooks.js and the group-role lookup in routes/api/v1/ingame.js - rather than +// pulling in the heavier noblox.js package (which is mainly aimed at cookie-authenticated game +// automation, not simple id/username lookups). +// +// All lookups are cached in-memory (including negative/not-found results) for CACHE_TTL_MS, both +// to keep the dashboard snappy when the same ids show up repeatedly (ACL entries, access group +// members, scan logs) and to avoid tripping Roblox's rate limits. + +const USERNAME_TO_ID_URL = 'https://users.roblox.com/v1/usernames/users'; +const IDS_TO_USERS_URL = 'https://users.roblox.com/v1/users'; +const singleUserUrl = (id) => `https://users.roblox.com/v1/users/${id}`; + +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const byId = new Map(); // id (string) -> { value: {id,name,displayName} | null, expires } +const byUsername = new Map(); // lowercased username -> same shape + +function getCached(map, key) { + const entry = map.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expires) { + map.delete(key); + return undefined; + } + return entry.value; +} + +function setCached(map, key, value) { + map.set(key, { value, expires: Date.now() + CACHE_TTL_MS }); +} + +// Caches a resolved user under both its id and username, so a lookup by either later hits cache. +function cacheUser(user) { + setCached(byId, String(user.id), user); + setCached(byUsername, user.name.toLowerCase(), user); +} + +// Splits an array into chunks of at most `size` items (Roblox's bulk endpoints are happy with +// large batches, but we chunk defensively so one call can't grow unbounded). +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +// Looks up a single Roblox user by username. Returns { id, name, displayName } or null if no such +// user exists (or the lookup failed). Both hits and misses are cached. +async function getUserByUsername(username) { + const key = String(username || '').trim().toLowerCase(); + if (!key) return null; + + const cached = getCached(byUsername, key); + if (cached !== undefined) return cached; + + try { + const res = await fetch(USERNAME_TO_ID_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ usernames: [username], excludeBannedUsers: false }) + }); + if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); + const body = await res.json(); + const match = (body.data || [])[0]; + const value = match ? { id: match.id, name: match.name, displayName: match.displayName } : null; + + setCached(byUsername, key, value); + if (value) setCached(byId, String(value.id), value); + return value; + } catch (err) { + console.error('Roblox username lookup failed:', err.message); + return null; + } +} + +// Looks up a single Roblox user by id. Returns { id, name, displayName } or null. +async function getUserById(userId) { + const key = String(userId || '').trim(); + if (!key || Number.isNaN(Number(key))) return null; + + const cached = getCached(byId, key); + if (cached !== undefined) return cached; + + try { + const res = await fetch(singleUserUrl(key)); + if (res.status === 404) { + setCached(byId, key, null); + return null; + } + if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); + const body = await res.json(); + const value = { id: body.id, name: body.name, displayName: body.displayName }; + cacheUser(value); + return value; + } catch (err) { + console.error('Roblox user id lookup failed:', err.message); + return null; + } +} + +// Batch-resolves multiple Roblox user ids to usernames at once (e.g. for a whole page of ACL +// entries or scan logs), to avoid one request per row. Returns a plain object mapping id (string) +// -> { id, name, displayName }; ids that don't resolve are simply omitted from the result. +async function getUsersByIds(userIds) { + const ids = [...new Set((userIds || []).map(id => String(id).trim()).filter(id => id && !Number.isNaN(Number(id))))]; + const result = {}; + const uncached = []; + + for (const id of ids) { + const cached = getCached(byId, id); + if (cached !== undefined) { + if (cached) result[id] = cached; + } else { + uncached.push(id); + } + } + + for (const batch of chunk(uncached, 100)) { + try { + const res = await fetch(IDS_TO_USERS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds: batch.map(Number), excludeBannedUsers: false }) + }); + if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); + const body = await res.json(); + + const found = new Set(); + for (const user of body.data || []) { + const value = { id: user.id, name: user.name, displayName: user.displayName }; + cacheUser(value); + result[String(user.id)] = value; + found.add(String(user.id)); + } + // Cache misses too, so we don't keep re-requesting ids that don't exist. + for (const id of batch) { + if (!found.has(id)) setCached(byId, id, null); + } + } catch (err) { + console.error('Roblox batch user lookup failed:', err.message); + } + } + + return result; +} + +module.exports = { getUserByUsername, getUserById, getUsersByIds }; diff --git a/public/css/style.css b/public/css/style.css index a869c86..ae817d2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -434,6 +434,40 @@ button.danger { font-size: 14px; } +.input-with-button { + display: flex; + gap: 8px; +} + +.input-with-button input { + flex: 1; + margin-bottom: 0; +} + +.input-with-button button { + white-space: nowrap; +} + +.field-hint { + display: block; + font-size: 12px; + margin: -12px 0 12px; + color: var(--muted); +} + +.field-hint.success { + color: var(--success); +} + +.field-hint.error { + color: var(--danger); +} + +.roblox-username { + color: var(--muted); + font-size: 12px; +} + .hidden { display: none !important; } diff --git a/public/dashboard/index.html b/public/dashboard/index.html index 79c273d..8177a27 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -206,7 +206,11 @@
- +
+ + +
+
- +
+ + +
+
-
- - -
+
-
- - -
+