notXCS/routes/api/v1/ingame.js

272 lines
10 KiB
JavaScript

let db;
const express = require('express');
const router = express.Router();
const { sendPlaceWebhook } = require('../../../lib/webhooks');
const { getUserById } = require('../../../lib/roblox');
module.exports = (dbInit) => {
db = dbInit;
return router;
};
// Formats a Roblox user id for display in a Discord embed field, appending the username if it can
// be resolved (e.g. "123456 (builderman)"). Falls back to just the id if the lookup fails/is unset.
async function formatUserIdForWebhook(userId) {
if (!userId) return 'unknown';
const user = await getUserById(userId);
return user ? `${userId} (${user.name})` : String(userId);
}
router.get('/', (req, res) => {
res.json({ success: true, message: "Welcome to the NOTXCS API!" });
});
router.get('/ping', (req, res) => {
// TODO: Implement a more robust health check. Probably will revolve around checking db conn and others.
res.json({ success: true, message: "pong" });
});
router.get('/:placeId', (req, res) => {
const placeId = req.params.placeId;
const apiKey = req.query.apiKey;
const universeid = req.query.universeid;
// For some reason the official XCS api doesn't actually check the api key when getting settings? So we won't either.
// XCS Also ignores universe ID. Probably used for some logging. We will ignore it.
if (!placeId) {
return res.status(404).json({ success: false, message: "What? How?" });
}
db.get(`SELECT * FROM places WHERE id = ?`, [placeId], (err, row) => {
if (err) {
console.error('Failed to retrieve place settings:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!row) {
return res.status(404).json({ success: false, message: "Place not found" });
}
db.all(`SELECT * FROM access_points WHERE placeId = ?`, [placeId], (err, accessPoints) => {
if (err) {
console.error('Failed to retrieve access points:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, lockdownRow) => {
if (err) {
console.error('Failed to retrieve lockdown state:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
// While a lockdown is active (either the global, all-places lockdown or this place's own
// lockdown), every reader is reported as inactive regardless of its own `enabled` setting.
const lockdownActive = !!(lockdownRow && lockdownRow.active) || !!row.lockdown;
let resp = {
success: true,
lockdown: lockdownActive,
accessPoints: accessPoints.reduce((acc, ap) => {
const armed = !!ap.armState; // Will have state 2 eventually for armed on schedule, but for now just 1 or 0.
acc[ap.id] = {
id: ap.id,
name: ap.name,
unlockTime: ap.unlockTime,
config: {
active: lockdownActive ? false : !!ap.enabled,
armed,
scanData: {
ready: JSON.parse(ap.readyData || '{}'),
disarmed: JSON.parse(ap.disarmedData || '{}')
}
}
}
return acc;
}, {})
};
// console.log(resp)
res.json(resp);
});
});
});
});
router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
// This one DOES check api key.
const placeId = req.params.placeId;
const accessPointId = req.params.accessPointId;
const apiKey = req.query.apiKey;
const userId = req.query.userId;
const cardNumbers = req.query.cardNumbers ? req.query.cardNumbers.split(',') : [];
const universeid = req.query.universeid || '0';
if (!placeId || !accessPointId) {
return res.status(404).json({ success: false, message: "What? How?" });
}
if (!apiKey) {
return res.status(400).json({ success: false, message: "API key is required" });
}
db.get(`SELECT * FROM places WHERE id = ? AND apiKey = ?`, [placeId, apiKey], (err, place) => {
if (err) {
console.error('Failed to retrieve place settings:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!place) {
return res.status(404).json({ success: false, message: "Place not found or invalid API key" });
}
db.get(`SELECT * FROM access_points WHERE id = ? AND placeId = ?`, [accessPointId, placeId], (err, ap) => {
if (err) {
console.error('Failed to retrieve access point:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!ap) {
return res.status(404).json({ success: false, message: "Access point not found" });
}
db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, lockdownRow) => {
if (err) {
console.error('Failed to retrieve lockdown state:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const globalLockdownActive = !!(lockdownRow && lockdownRow.active);
const placeLockdownActive = !!place.lockdown;
if (globalLockdownActive || placeLockdownActive) {
// Every reader is disabled during a lockdown (either global, all-places, or just this
// place) - deny immediately without consulting the ACL, and log it distinctly so it's
// clear this wasn't a normal denial.
db.run(
`INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`,
[placeId, accessPointId, userId || null, cardNumbers.join(','), 0, globalLockdownActive ? 'lockdown' : 'place_lockdown'],
(err) => {
if (err) console.error('Failed to record scan log:', err.message);
}
);
formatUserIdForWebhook(userId).then((userLabel) => {
sendPlaceWebhook(place, 'access_denied', {
Reader: ap.name,
'User ID': userLabel,
'Card(s)': cardNumbers.join(', ') || undefined,
Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
});
});
return res.json({
success: true,
grant_type: 'user_scan',
response_code: 'access_denied',
response_time: 0,
scan_data: JSON.parse(ap.deniedData || '{}')
});
}
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [accessPointId], async (err, aclEntries) => {
if (err) {
console.error('Failed to retrieve ACL entries:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
// Access group entries (type 5) reference an access_groups.id in `data`. Resolve their
// members up front so the group's users/cards/ranks are checked just like direct entries.
const groupIds = [...new Set(aclEntries.filter(e => e.type === 5).map(e => e.data))];
let groupMembersById = {};
if (groupIds.length > 0) {
const placeholders = groupIds.map(() => '?').join(',');
groupMembersById = await new Promise((resolve) => {
db.all(`SELECT * FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err, members) => {
if (err) {
console.error('Failed to retrieve access group members:', err.message);
return resolve({});
}
const byGroup = {};
for (const member of members) {
// Groups can only contain direct credentials (types 0-4); the API rejects
// nested groups on write, but skip any type 5 here too as defense-in-depth
// against infinite recursion if the database is ever modified directly.
if (member.type === 5) continue;
if (!byGroup[member.groupId]) byGroup[member.groupId] = [];
byGroup[member.groupId].push(member);
}
resolve(byGroup);
});
});
}
let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []);
userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank}));
// console.log('User groups:', userGroups);
// Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group
function matchesEntry(entry) {
switch (entry.type) {
case 0: // User ID
return entry.data == userId;
case 1: // Card Number
return cardNumbers.includes(entry.data);
case 2: { // Group Exact Rank
const [group, rank] = entry.data.split(':');
return userGroups.some(g => g.group == group && g.rank == rank);
}
case 3: { // Group Min Rank
const [group, rank] = entry.data.split(':');
return userGroups.some(g => g.group == group && g.rank >= rank);
}
case 4: // Allow All
return true;
case 5: // Access Group - granted if any credential in the group matches.
return (groupMembersById[entry.data] || []).some(matchesEntry);
default:
return false;
}
}
let granted = false;
// Check every entry (users, cards, group ranks, then access groups made up of the above).
for (const entry of aclEntries) {
if (matchesEntry(entry)) {
granted = true;
break;
}
}
const responseCode = granted ? 'access_granted' : 'access_denied';
db.run(
`INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`,
[placeId, accessPointId, userId || null, cardNumbers.join(','), granted ? 1 : 0, responseCode],
(err) => {
if (err) console.error('Failed to record scan log:', err.message);
}
);
formatUserIdForWebhook(userId).then((userLabel) => {
sendPlaceWebhook(place, responseCode, {
Reader: ap.name,
'User ID': userLabel,
'Card(s)': cardNumbers.join(', ') || undefined
});
});
const responseData = granted ? {
success: true,
grant_type: 'user_scan',
response_code: 'access_granted',
response_time: 0, // This is just analytics for how long their backend took to generate the response
scan_data: JSON.parse(ap.grantedData || '{}')
} : {
success: true,
grant_type: 'user_scan',
response_code: 'access_denied',
response_time: 0,
scan_data: JSON.parse(ap.deniedData || '{}')
}
res.json(responseData);
});
});
});
});
});