272 lines
10 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}); |