notXCS/routes/dashboard.js

1009 lines
42 KiB
JavaScript

const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
const { getEffectiveOrgRole } = require('../lib/orgs');
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
const { getUserByUsername, getUserById, getUsersByIds } = require('../lib/roblox');
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
let db;
module.exports = (dbInit) => {
db = dbInit;
return router;
};
// Loads an organization by id and determines the caller's effective role in it (their membership
// role, or an Admin-level bypass for platform elevated/admin/superadmin users). Calls back with
// `{ ok: true, org, role, bypass }` on success, or `{ ok: false, status, message }` on failure -
// used both by the `loadOrg` middleware below and directly wherever an orgId comes from a request
// body rather than a route param (e.g. POST /places).
function checkOrgAccess(orgId, user, minRole, callback) {
if (!orgId || Number.isNaN(orgId)) {
return callback(null, { ok: false, status: 400, message: "A valid organization id is required" });
}
db.get(`SELECT * FROM organizations WHERE id = ?`, [orgId], (err, org) => {
if (err) return callback(err);
if (!org) return callback(null, { ok: false, status: 404, message: "Organization not found" });
getEffectiveOrgRole(db, orgId, user, (err, result) => {
if (err) return callback(err);
if (!result.role) return callback(null, { ok: false, status: 404, message: "Organization not found" });
if (result.role < minRole) return callback(null, { ok: false, status: 403, message: "Insufficient permissions in this organization" });
callback(null, { ok: true, org, role: result.role, bypass: result.bypass });
});
});
}
// Express middleware version of checkOrgAccess, for routes with an :orgId param.
function loadOrg(minRole = ORG_ROLES.VIEW) {
return (req, res, next) => {
const orgId = parseInt(req.params.orgId, 10);
checkOrgAccess(orgId, req.user, minRole, (err, result) => {
if (err) {
console.error('Failed to check organization access:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!result.ok) {
return res.status(result.status).json({ success: false, message: result.message });
}
req.org = result.org;
req.orgRole = result.role;
req.orgBypass = result.bypass;
next();
});
};
}
// Loads the place, granting access based on the caller's role in the organization that owns it:
// any membership role (View/Edit/Admin) grants at least read access, while platform elevated/admin
// users get an Admin-level bypass even if they aren't a member of the org.
function loadPlace(req, res, next) {
const { placeId } = req.params;
db.get(`SELECT * FROM places WHERE id = ?`, [placeId], (err, place) => {
if (err) {
console.error('Failed to retrieve place:', 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" });
}
checkOrgAccess(place.orgId, req.user, ORG_ROLES.VIEW, (err, result) => {
if (err) {
console.error('Failed to check organization access:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!result.ok) {
return res.status(404).json({ success: false, message: "Place not found" });
}
req.place = place;
req.org = result.org;
req.orgRole = result.role;
req.orgBypass = result.bypass;
next();
});
});
}
// Requires Edit (or higher) organization access to the place loaded by `loadPlace` - used for every
// route that creates/modifies/deletes a place, reader, ACL entry or access group. View-only members
// can still use the GET routes above, which only require `loadPlace` on its own.
function requireOrgEdit(req, res, next) {
if (req.orgRole >= ORG_ROLES.EDIT) {
return next();
}
return res.status(403).json({ success: false, message: "You need Edit access in this organization to perform this action" });
}
// Requires Admin organization access - used for managing organization membership/roles and for
// renaming/deleting the organization itself.
function requireOrgAdmin(req, res, next) {
if (req.orgRole >= ORG_ROLES.ADMIN) {
return next();
}
return res.status(403).json({ success: false, message: "You need Admin access in this organization to perform this action" });
}
router.use(requireAuth(() => db));
// Read-only lockdown status check, available to any authenticated user (not just admins) so the
// dashboard can show a banner while readers are disabled site-wide.
router.get('/lockdown', (req, res) => {
db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, row) => {
if (err) {
console.error('Failed to retrieve lockdown state:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, active: !!(row && row.active) });
});
});
// --- Roblox username/id lookups (used to resolve usernames typed into ACL/group forms to ids for
// storage, and to show usernames alongside ids wherever they're displayed) ---
// Resolves a Roblox username to its user id (?username=). Used by the "look up" button next to
// User ID fields in the dashboard, so users can type a username instead of hunting for the id.
router.get('/roblox/lookup', async (req, res) => {
const username = req.query.username;
if (!username) {
return res.status(400).json({ success: false, message: "A username is required" });
}
const user = await getUserByUsername(username);
if (!user) {
return res.status(404).json({ success: false, message: "No Roblox user with that username was found" });
}
res.json({ success: true, user });
});
// Batch-resolves Roblox user ids to usernames (?ids=1,2,3). Used to annotate ACL entries, access
// group members and scan logs with the corresponding username wherever a user id is shown.
router.get('/roblox/users', async (req, res) => {
const ids = (req.query.ids || '').split(',').map(s => s.trim()).filter(Boolean);
if (ids.length === 0) {
return res.json({ success: true, users: {} });
}
const users = await getUsersByIds(ids);
res.json({ success: true, users });
});
// --- Organizations ---
// List organizations the current user belongs to, plus (for platform elevated/admin/superadmin
// users) every other organization too, so they retain the old "see everything" bypass. Each entry
// includes the caller's effective role in that org (`role`) and whether it comes from platform
// bypass rather than real membership (`bypass`), so the frontend can grey out admin-only actions
// for orgs the user doesn't actually belong to.
router.get('/orgs', (req, res) => {
if (req.user.role >= ROLES.ELEVATED) {
return db.all(
`SELECT organizations.*, organization_members.role AS memberRole
FROM organizations
LEFT JOIN organization_members ON organization_members.orgId = organizations.id AND organization_members.userId = ?
ORDER BY organizations.id`,
[req.user.id],
(err, orgs) => {
if (err) {
console.error('Failed to retrieve organizations:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({
success: true,
orgs: orgs.map(o => ({
id: o.id,
name: o.name,
ownerId: o.ownerId,
createdAt: o.createdAt,
role: o.memberRole != null ? o.memberRole : ORG_ROLES.ADMIN,
isMember: o.memberRole != null,
bypass: o.memberRole == null
}))
});
}
);
}
db.all(
`SELECT organizations.*, organization_members.role AS memberRole
FROM organizations
JOIN organization_members ON organization_members.orgId = organizations.id
WHERE organization_members.userId = ?
ORDER BY organizations.id`,
[req.user.id],
(err, orgs) => {
if (err) {
console.error('Failed to retrieve organizations:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({
success: true,
orgs: orgs.map(o => ({
id: o.id,
name: o.name,
ownerId: o.ownerId,
createdAt: o.createdAt,
role: o.memberRole,
isMember: true,
bypass: false
}))
});
}
);
});
// Create a new organization. The creator becomes its owner and an Admin member.
router.post('/orgs', (req, res) => {
const name = req.body && req.body.name ? String(req.body.name).trim() : '';
if (!name) {
return res.status(400).json({ success: false, message: "Organization name is required" });
}
db.run(`INSERT INTO organizations (name, ownerId) VALUES (?, ?)`, [name, req.user.id], function (err) {
if (err) {
console.error('Failed to create organization:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const orgId = this.lastID;
db.run(`INSERT INTO organization_members (orgId, userId, role, invitedBy) VALUES (?, ?, ?, ?)`, [orgId, req.user.id, ORG_ROLES.ADMIN, req.user.id], (err) => {
if (err) {
console.error('Failed to add creator to new organization:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, org: { id: orgId, name, ownerId: req.user.id, role: ORG_ROLES.ADMIN } });
});
});
});
// Rename an organization
router.put('/orgs/:orgId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
const name = req.body && req.body.name ? String(req.body.name).trim() : '';
if (!name) {
return res.status(400).json({ success: false, message: "Organization name is required" });
}
db.run(`UPDATE organizations SET name = ? WHERE id = ?`, [name, req.org.id], (err) => {
if (err) {
console.error('Failed to rename organization:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
// Delete an organization. Only allowed once it owns no places, so places are never silently
// orphaned - the caller must move or delete them first.
router.delete('/orgs/:orgId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
db.get(`SELECT COUNT(*) as count FROM places WHERE orgId = ?`, [req.org.id], (err, row) => {
if (err) {
console.error('Failed to check places for organization:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (row.count > 0) {
return res.status(409).json({ success: false, message: "This organization still owns places. Move or delete them first." });
}
db.run(`DELETE FROM organization_members WHERE orgId = ?`, [req.org.id], (err) => {
if (err) {
console.error('Failed to delete organization members:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM organizations WHERE id = ?`, [req.org.id], (err) => {
if (err) {
console.error('Failed to delete organization:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
});
// List members of an organization. Any member (View and up) can see who else is in the org, but
// only Admins can add/remove members or change roles (see routes below).
router.get('/orgs/:orgId/members', loadOrg(ORG_ROLES.VIEW), (req, res) => {
db.all(
`SELECT organization_members.id, organization_members.userId, users.username, organization_members.role, organization_members.createdAt
FROM organization_members JOIN users ON users.id = organization_members.userId
WHERE organization_members.orgId = ?
ORDER BY organization_members.role DESC, users.username`,
[req.org.id],
(err, members) => {
if (err) {
console.error('Failed to retrieve organization members:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, members });
}
);
});
const VALID_ORG_ROLES = [ORG_ROLES.VIEW, ORG_ROLES.EDIT, ORG_ROLES.ADMIN];
// Add a member to the organization by username, with a chosen role (View/Edit/Admin, scoped to
// this organization only - not a platform-wide role). Only Admins may do this.
router.post('/orgs/:orgId/members', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
const { username } = req.body || {};
const role = req.body && req.body.role !== undefined ? parseInt(req.body.role, 10) : ORG_ROLES.VIEW;
if (!username) {
return res.status(400).json({ success: false, message: "Username is required" });
}
if (!VALID_ORG_ROLES.includes(role)) {
return res.status(400).json({ success: false, message: "Invalid role" });
}
db.get(`SELECT id FROM users WHERE username = ?`, [username], (err, user) => {
if (err) {
console.error('Failed to look up user:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!user) {
return res.status(404).json({ success: false, message: "No user with that username was found" });
}
db.run(
`INSERT INTO organization_members (orgId, userId, role, invitedBy) VALUES (?, ?, ?, ?)`,
[req.org.id, user.id, role, req.user.id],
function (err) {
if (err) {
if (err.message && err.message.includes('UNIQUE')) {
return res.status(409).json({ success: false, message: "That user is already a member of this organization" });
}
console.error('Failed to add organization member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, member: { id: this.lastID, orgId: req.org.id, userId: user.id, username, role } });
}
);
});
});
// Change a member's role within the organization. Only Admins may do this, and the organization's
// owner can never be demoted (their role always stays Admin), so the org can't be left without an
// admin who's guaranteed access.
router.put('/orgs/:orgId/members/:userId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
const targetUserId = parseInt(req.params.userId, 10);
const role = parseInt(req.body ? req.body.role : undefined, 10);
if (!VALID_ORG_ROLES.includes(role)) {
return res.status(400).json({ success: false, message: "Invalid role" });
}
if (targetUserId === req.org.ownerId && role !== ORG_ROLES.ADMIN) {
return res.status(400).json({ success: false, message: "The organization owner must remain an Admin" });
}
db.run(`UPDATE organization_members SET role = ? WHERE orgId = ? AND userId = ?`, [role, req.org.id, targetUserId], function (err) {
if (err) {
console.error('Failed to update organization member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (this.changes === 0) {
return res.status(404).json({ success: false, message: "That user is not a member of this organization" });
}
res.json({ success: true });
});
});
// Remove a member from the organization. Only Admins may do this, and the organization's owner can
// never be removed (they must delete the organization itself instead, once it owns no places).
router.delete('/orgs/:orgId/members/:userId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
const targetUserId = parseInt(req.params.userId, 10);
if (targetUserId === req.org.ownerId) {
return res.status(400).json({ success: false, message: "The organization owner cannot be removed" });
}
db.run(`DELETE FROM organization_members WHERE orgId = ? AND userId = ?`, [req.org.id, targetUserId], (err) => {
if (err) {
console.error('Failed to remove organization member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
// --- Places (each belongs to exactly one organization; access is governed by org membership) ---
// List places belonging to the given organization (?orgId=). Requires at least View access to
// that organization.
router.get('/places', (req, res) => {
const orgId = parseInt(req.query.orgId, 10);
checkOrgAccess(orgId, req.user, ORG_ROLES.VIEW, (err, result) => {
if (err) {
console.error('Failed to check organization access:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!result.ok) {
return res.status(result.status).json({ success: false, message: result.message });
}
db.all(`SELECT * FROM places WHERE orgId = ?`, [orgId], (err, places) => {
if (err) {
console.error('Failed to retrieve places:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, places, orgRole: result.role });
});
});
});
// Create a new place within an organization (body.orgId). Requires Edit access in that organization.
// Only admins (and superadmins) may supply a custom id/apiKey - everyone else always gets a
// generated (random UUID / random 64-char) id and apiKey. Admins that leave either field blank also
// get one generated for them.
router.post('/places', (req, res) => {
const orgId = parseInt(req.body ? req.body.orgId : undefined, 10);
checkOrgAccess(orgId, req.user, ORG_ROLES.EDIT, (err, result) => {
if (err) {
console.error('Failed to check organization access:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!result.ok) {
return res.status(result.status).json({ success: false, message: result.message });
}
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
const { settings } = req.body || {};
let { id, apiKey, name } = req.body || {};
if (!canSetCustomValues && (id || apiKey)) {
return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" });
}
// If we reach here, either the caller is an admin, or both id and apiKey were already falsy.
id = id ? String(id).trim() : '';
apiKey = apiKey ? String(apiKey).trim() : '';
name = name ? String(name).trim() : '';
if (!id) id = generatePlaceId();
if (!apiKey) apiKey = generateApiKey();
db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
if (err) {
console.error('Failed to check for existing place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (existing) {
return res.status(409).json({ success: false, message: "A place with that id already exists" });
}
db.run(`INSERT INTO places (id, apiKey, settings, ownerId, name, orgId) VALUES (?, ?, ?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id, name || null, orgId], (err) => {
if (err) {
console.error('Failed to create place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id, name: name || null, orgId } });
});
});
});
});
// Get a single place with its access points (readers)
router.get('/places/:placeId', loadPlace, (req, res) => {
db.all(`SELECT * FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
if (err) {
console.error('Failed to retrieve access points:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, place: req.place, accessPoints, orgRole: req.orgRole });
});
});
// Update a place's apiKey/settings. Requires Edit access in the place's organization. Only admins
// (and superadmins) may set a custom apiKey value - everyone else must use the regenerate-key
// endpoint below. Admins that explicitly send a blank apiKey get a freshly generated one instead
// of clearing it.
router.put('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => {
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
if (req.body.apiKey !== undefined && !canSetCustomValues) {
return res.status(403).json({ success: false, message: "Only admins can set a custom API key; use regenerate instead" });
}
let apiKey = req.place.apiKey;
if (req.body.apiKey !== undefined) {
apiKey = String(req.body.apiKey).trim() || generateApiKey();
}
const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings;
const name = req.body.name !== undefined ? (String(req.body.name).trim() || null) : req.place.name;
db.run(`UPDATE places SET apiKey = ?, settings = ?, name = ? WHERE id = ?`, [apiKey, settings, name, req.place.id], (err) => {
if (err) {
console.error('Failed to update place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
// Update a place's Discord webhook configuration: the webhook URL, an optional custom message
// `content` (e.g. to ping a role/user), and which event types to notify for. Requires Edit access
// in the place's organization, same as other place settings.
router.put('/places/:placeId/webhook', loadPlace, requireOrgEdit, (req, res) => {
const body = req.body || {};
const webhookUrl = body.webhookUrl !== undefined ? (String(body.webhookUrl).trim() || null) : req.place.webhookUrl;
if (webhookUrl && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(webhookUrl)) {
return res.status(400).json({ success: false, message: "That doesn't look like a valid Discord webhook URL" });
}
const webhookContent = body.webhookContent !== undefined ? (String(body.webhookContent).trim() || null) : req.place.webhookContent;
const webhookAccessGranted = body.webhookAccessGranted !== undefined ? (body.webhookAccessGranted ? 1 : 0) : req.place.webhookAccessGranted;
const webhookAccessDenied = body.webhookAccessDenied !== undefined ? (body.webhookAccessDenied ? 1 : 0) : req.place.webhookAccessDenied;
const webhookReaderArm = body.webhookReaderArm !== undefined ? (body.webhookReaderArm ? 1 : 0) : req.place.webhookReaderArm;
const webhookReaderEnable = body.webhookReaderEnable !== undefined ? (body.webhookReaderEnable ? 1 : 0) : req.place.webhookReaderEnable;
db.run(
`UPDATE places SET webhookUrl = ?, webhookContent = ?, webhookAccessGranted = ?, webhookAccessDenied = ?, webhookReaderArm = ?, webhookReaderEnable = ? WHERE id = ?`,
[webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable, req.place.id],
(err) => {
if (err) {
console.error('Failed to update place webhook settings:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({
success: true,
webhook: { webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable }
});
}
);
});
// Send a one-off test embed to the place's currently-configured (or just-entered, via body.webhookUrl)
// Discord webhook, so the user can confirm it's set up correctly before relying on it.
router.post('/places/:placeId/webhook/test', loadPlace, requireOrgEdit, async (req, res) => {
const webhookUrl = (req.body && req.body.webhookUrl !== undefined ? req.body.webhookUrl : req.place.webhookUrl);
const content = (req.body && req.body.webhookContent !== undefined ? req.body.webhookContent : req.place.webhookContent);
const result = await sendTestWebhook(webhookUrl, content);
if (!result.ok) {
return res.status(400).json({ success: false, message: result.message || 'Failed to send test webhook' });
}
res.json({ success: true });
});
// Move a place to a different organization. Requires Edit access in the place's current
// organization, and at least Edit access in the destination organization.
router.post('/places/:placeId/move', loadPlace, requireOrgEdit, (req, res) => {
const destOrgId = parseInt(req.body ? req.body.orgId : undefined, 10);
checkOrgAccess(destOrgId, req.user, ORG_ROLES.EDIT, (err, result) => {
if (err) {
console.error('Failed to check destination organization access:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!result.ok) {
return res.status(result.status).json({ success: false, message: result.message });
}
db.run(`UPDATE places SET orgId = ? WHERE id = ?`, [destOrgId, req.place.id], (err) => {
if (err) {
console.error('Failed to move place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
// Regenerate a place's API key to a new random value. Requires Edit access in the place's
// organization - unlike setting a custom value, this doesn't require the platform admin role since
// it never lets the caller choose the resulting key.
router.post('/places/:placeId/regenerate-key', loadPlace, requireOrgEdit, (req, res) => {
const apiKey = generateApiKey();
db.run(`UPDATE places SET apiKey = ? WHERE id = ?`, [apiKey, req.place.id], (err) => {
if (err) {
console.error('Failed to regenerate API key:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, apiKey });
});
});
// Engage a lockdown for this place only, disabling every reader at this place until it's lifted.
// Requires Edit access in the place's organization - unlike the global (all-places) lockdown in
// routes/admin.js, it doesn't require the platform admin role.
router.post('/places/:placeId/lockdown', loadPlace, requireOrgEdit, (req, res) => {
db.run(
`UPDATE places SET lockdown = 1, lockdownActivatedBy = ?, lockdownActivatedAt = CURRENT_TIMESTAMP WHERE id = ?`,
[req.user.id, req.place.id],
(err) => {
if (err) {
console.error('Failed to engage place lockdown:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, lockdown: true });
}
);
});
// Lift this place's lockdown, restoring every reader at this place to its own configured state.
router.delete('/places/:placeId/lockdown', loadPlace, requireOrgEdit, (req, res) => {
db.run(
`UPDATE places SET lockdown = 0, lockdownActivatedBy = NULL, lockdownActivatedAt = NULL WHERE id = ?`,
[req.place.id],
(err) => {
if (err) {
console.error('Failed to lift place lockdown:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, lockdown: false });
}
);
});
// Download the place's Roblox kit (xcs-template.rbxmx) with the placeId/apiKey placeholders
// filled in for this specific place. Available to anyone with (at least View) access to the place.
router.get('/places/:placeId/kit', loadPlace, (req, res) => {
fs.readFile(KIT_TEMPLATE_PATH, 'utf8', (err, template) => {
if (err) {
console.error('Failed to read kit template:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const kit = template
.split('%%PLACEID%%').join(req.place.id)
.split('%%APIKEY%%').join(req.place.apiKey);
const filename = `${(req.place.name || req.place.id).replace(/[^a-zA-Z0-9-_]/g, '_')}.rbxmx`;
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(kit);
});
});
// Delete a place (and its readers/acl entries/access groups/scan logs). Requires Edit access in
// the place's organization.
router.delete('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => {
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
if (err) {
console.error('Failed to retrieve access points:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const apIds = accessPoints.map(ap => ap.id);
const deleteAclAndAp = (cb) => {
if (apIds.length === 0) return cb();
// Build a `?` placeholder per id so all values stay parameterized; apIds never contains raw user input here.
const placeholders = apIds.map(() => '?').join(',');
db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => {
if (err) return cb(err);
db.run(`DELETE FROM scan_logs WHERE accessPoint IN (${placeholders})`, apIds, (err) => {
if (err) return cb(err);
db.run(`DELETE FROM access_points WHERE placeId = ?`, [req.place.id], cb);
});
});
};
deleteAclAndAp((err) => {
if (err) {
console.error('Failed to delete readers for place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.all(`SELECT id FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
if (err) {
console.error('Failed to retrieve access groups for place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const groupIds = groups.map(g => g.id);
const deleteGroups = (cb) => {
if (groupIds.length === 0) return cb();
const placeholders = groupIds.map(() => '?').join(',');
db.run(`DELETE FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err) => {
if (err) return cb(err);
db.run(`DELETE FROM access_groups WHERE placeId = ?`, [req.place.id], cb);
});
};
deleteGroups((err) => {
if (err) {
console.error('Failed to delete access groups for place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => {
if (err) {
console.error('Failed to delete place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
});
});
});
function loadOwnedAccessPoint(req, res, next) {
db.get(`SELECT * FROM access_points WHERE id = ? AND placeId = ?`, [req.params.accessPointId, req.place.id], (err, ap) => {
if (err) {
console.error('Failed to retrieve reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!ap) {
return res.status(404).json({ success: false, message: "Reader not found" });
}
req.accessPoint = ap;
next();
});
}
// Create a new reader (access point) for a place. The reader id is always
// generated server-side as a random 16-character alphanumeric string; no
// caller-supplied id is ever accepted.
router.post('/places/:placeId/access-points', loadPlace, requireOrgEdit, (req, res) => {
const { name } = req.body || {};
if (!name) {
return res.status(400).json({ success: false, message: "Reader name is required" });
}
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : 1;
const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : 8;
const armState = req.body.armState !== undefined ? req.body.armState : 1;
function tryInsert(attemptsLeft) {
const id = generateReaderId();
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
if (err) {
console.error('Failed to check for existing reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (existing) {
if (attemptsLeft <= 0) {
return res.status(500).json({ success: false, message: "Failed to generate a unique reader id" });
}
return tryInsert(attemptsLeft - 1);
}
db.run(
`INSERT INTO access_points (id, placeId, name, enabled, unlockTime, armState) VALUES (?, ?, ?, ?, ?, ?)`,
[id, req.place.id, name, enabled, unlockTime, armState],
(err) => {
if (err) {
console.error('Failed to create reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, id });
}
);
});
}
tryInsert(5);
});
// Update a reader's settings
router.put('/places/:placeId/access-points/:accessPointId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
const ap = req.accessPoint;
const name = req.body.name !== undefined ? req.body.name : ap.name;
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : ap.enabled;
const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : ap.unlockTime;
const armState = req.body.armState !== undefined ? req.body.armState : ap.armState;
const readyData = req.body.readyData !== undefined ? req.body.readyData : ap.readyData;
const disarmedData = req.body.disarmedData !== undefined ? req.body.disarmedData : ap.disarmedData;
const grantedData = req.body.grantedData !== undefined ? req.body.grantedData : ap.grantedData;
const deniedData = req.body.deniedData !== undefined ? req.body.deniedData : ap.deniedData;
db.run(
`UPDATE access_points SET name = ?, enabled = ?, unlockTime = ?, armState = ?, readyData = ?, disarmedData = ?, grantedData = ?, deniedData = ? WHERE id = ?`,
[name, enabled, unlockTime, armState, readyData, disarmedData, grantedData, deniedData, ap.id],
(err) => {
if (err) {
console.error('Failed to update reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
// Fire webhooks for any state actually toggled by this request, named after who did it.
const actor = req.user ? req.user.username : 'someone';
if (!!enabled !== !!ap.enabled) {
sendPlaceWebhook(req.place, enabled ? 'reader_enabled' : 'reader_disabled', {
Reader: name,
By: actor
});
}
if (!!armState !== !!ap.armState) {
sendPlaceWebhook(req.place, armState ? 'reader_armed' : 'reader_disarmed', {
Reader: name,
By: actor
});
}
res.json({ success: true });
}
);
});
// Delete a reader
router.delete('/places/:placeId/access-points/:accessPointId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
db.run(`DELETE FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
if (err) {
console.error('Failed to delete ACL entries for reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM scan_logs WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
if (err) {
console.error('Failed to delete scan logs for reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM access_points WHERE id = ?`, [req.accessPoint.id], (err) => {
if (err) {
console.error('Failed to delete reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
});
// List scan logs for a reader (most recent first)
router.get('/places/:placeId/access-points/:accessPointId/logs', loadPlace, loadOwnedAccessPoint, (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
db.all(
`SELECT * FROM scan_logs WHERE accessPoint = ? ORDER BY scannedAt DESC, id DESC LIMIT ?`,
[req.accessPoint.id, limit],
(err, logs) => {
if (err) {
console.error('Failed to retrieve scan logs:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, logs });
}
);
});
// List ACL entries for a reader
router.get('/places/:placeId/access-points/:accessPointId/acl', loadPlace, loadOwnedAccessPoint, (req, res) => {
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err, entries) => {
if (err) {
console.error('Failed to retrieve ACL entries:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, acl: entries });
});
});
// Add an ACL entry for a reader
router.post('/places/:placeId/access-points/:accessPointId/acl', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
const { type, data } = req.body || {};
if (type === undefined || !data) {
return res.status(400).json({ success: false, message: "ACL type and data are required" });
}
db.run(`INSERT INTO acl (accessPoint, type, data) VALUES (?, ?, ?)`, [req.accessPoint.id, type, data], function (err) {
if (err) {
console.error('Failed to create ACL entry:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, id: this.lastID });
});
});
// Delete an ACL entry
router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
db.run(`DELETE FROM acl WHERE id = ? AND accessPoint = ?`, [req.params.aclId, req.accessPoint.id], (err) => {
if (err) {
console.error('Failed to delete ACL entry:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
function loadOwnedAccessGroup(req, res, next) {
db.get(`SELECT * FROM access_groups WHERE id = ? AND placeId = ?`, [req.params.groupId, req.place.id], (err, group) => {
if (err) {
console.error('Failed to retrieve access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!group) {
return res.status(404).json({ success: false, message: "Access group not found" });
}
req.accessGroup = group;
next();
});
}
// List access groups for a place
router.get('/places/:placeId/access-groups', loadPlace, (req, res) => {
db.all(`SELECT * FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
if (err) {
console.error('Failed to retrieve access groups:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, groups });
});
});
// Create an access group (e.g. "Staff") for a place
router.post('/places/:placeId/access-groups', loadPlace, requireOrgEdit, (req, res) => {
const { name } = req.body || {};
if (!name) {
return res.status(400).json({ success: false, message: "Access group name is required" });
}
db.run(`INSERT INTO access_groups (placeId, name) VALUES (?, ?)`, [req.place.id, name], function (err) {
if (err) {
console.error('Failed to create access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, group: { id: this.lastID, placeId: req.place.id, name } });
});
});
// Rename an access group
router.put('/places/:placeId/access-groups/:groupId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
const name = req.body.name !== undefined ? req.body.name : req.accessGroup.name;
if (!name) {
return res.status(400).json({ success: false, message: "Access group name is required" });
}
db.run(`UPDATE access_groups SET name = ? WHERE id = ?`, [name, req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to update access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
// Delete an access group (and its members, and any ACL entries referencing it)
router.delete('/places/:placeId/access-groups/:groupId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
db.run(`DELETE FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to delete access group members:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
// Access group ACL entries store the group id in `data` with type 5.
db.run(`DELETE FROM acl WHERE type = 5 AND data = ?`, [String(req.accessGroup.id)], (err) => {
if (err) {
console.error('Failed to delete ACL entries referencing access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM access_groups WHERE id = ?`, [req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to delete access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
});
// List members (creds) of an access group
router.get('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOwnedAccessGroup, (req, res) => {
db.all(`SELECT * FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err, members) => {
if (err) {
console.error('Failed to retrieve access group members:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, members });
});
});
// Add a member (user, card, group rank rule, etc) to an access group
router.post('/places/:placeId/access-groups/:groupId/members', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
const { type, data } = req.body || {};
if (type === undefined || !data) {
return res.status(400).json({ success: false, message: "Member type and data are required" });
}
// Groups cannot contain other groups, to avoid nested/circular resolution.
if (parseInt(type, 10) === 5) {
return res.status(400).json({ success: false, message: "An access group cannot contain another access group, to prevent circular/nested resolution" });
}
db.run(`INSERT INTO access_group_members (groupId, type, data) VALUES (?, ?, ?)`, [req.accessGroup.id, type, data], function (err) {
if (err) {
console.error('Failed to add access group member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, id: this.lastID });
});
});
// Remove a member from an access group
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
db.run(`DELETE FROM access_group_members WHERE id = ? AND groupId = ?`, [req.params.memberId, req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to remove access group member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});