148 lines
5.2 KiB
JavaScript
148 lines
5.2 KiB
JavaScript
// 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 };
|