I'm going to shoot someone

This commit is contained in:
Christopher Cookman 2026-02-19 20:00:24 -07:00
commit 2fcdfa203c
8 changed files with 2699 additions and 0 deletions

145
.gitignore vendored Normal file
View file

@ -0,0 +1,145 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
database.db

66
index.js Normal file
View file

@ -0,0 +1,66 @@
require('dotenv').config({ quiet: true });
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const app = express();
const port = process.env.PORT || 3000;
const db = new sqlite3.Database('./database.db', (err) => {
if (err) {
console.error('Failed to connect to the database:', err.message);
} else {
console.log('Connected to the SQLite database.');
require('./migrations')(db).then(() => {
app.listen(port, () => {
console.log(`NOTXCS API is running on port ${port}`);
});
}).catch(err => {
console.error('Failed to run migrations:', err.message);
});
};
});
global.db = db;
app.use(express.static('public'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
console.log(`${req.method} ${req.originalUrl}`);
next();
});
// Load routes dynamically
const fs = require('fs');
const path = require('path');
const routesPath = path.join(__dirname, 'routes');
function loadRoutes(dir, basePath = '/') {
const files = fs.readdirSync(dir);
files.forEach((file) => {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
loadRoutes(fullPath, path.join(basePath, file));
} else if (file.endsWith('.js')) {
const routeName = path.basename(file, '.js');
const routeUrl = path.join(basePath, routeName === 'index' ? '' : routeName).replace(/\\/g, '/');
try {
const route = require(fullPath);
// If the module exports a router/function, use it
if (typeof route === 'function' || route.name === 'router') {
app.use(routeUrl, route);
console.log(`Loaded route: ${routeUrl}`);
}
} catch (err) {
console.error(`Error loading route ${fullPath}:`, err);
}
}
});
}
loadRoutes(routesPath);

56
migrations.js Normal file
View file

@ -0,0 +1,56 @@
const fs = require('fs').promises;
const path = require('path');
const util = require('util');
const colors = require('colors');
module.exports = (db) => {
const run = util.promisify(db.run.bind(db));
const get = util.promisify(db.get.bind(db));
const exec = util.promisify(db.exec.bind(db));
return new Promise((resolve, reject) => {
(async () => {
try {
await run(`CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
const migrationsDir = path.join(__dirname, 'migrations');
let files;
try {
files = await fs.readdir(migrationsDir);
} catch (e) {
if (e.code === 'ENOENT') return resolve(); // no migrations directory
throw e;
}
files = files.filter(f => path.extname(f).toLowerCase() === '.sql').sort();
for (const file of files) {
const name = file;
const already = await get('SELECT 1 FROM migrations WHERE name = ?', [name]);
if (already) continue;
const sql = await fs.readFile(path.join(migrationsDir, file), 'utf8');
await exec('BEGIN');
try {
await exec(sql);
console.log(`${colors.yellow('[MIGRATION]')} Applied migration: ${name}`);
await run('INSERT INTO migrations (name) VALUES (?)', [name]);
await exec('COMMIT');
} catch (e) {
console.error(`${colors.red('[MIGRATION]')} Failed migration: ${name}`);
await exec('ROLLBACK');
throw e;
}
}
console.log(`${colors.green('[MIGRATION]')} All migrations applied.`);
resolve();
} catch (err) {
reject(err);
}
})();
});
}

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS places (id TEXT PRIMARY KEY, apiKey TEXT, settings TEXT);
CREATE TABLE IF NOT EXISTS access_points (id TEXT PRIMARY KEY, placeId TEXT, name TEXT, enabled INTEGER DEFAULT 1, unlockTime INTEGER DEFAULT 8, armState INTEGER DEFAULT 1, readyData TEXT DEFAULT '{}', disarmedData TEXT DEFAULT '{}', grantedData TEXT DEFAULT '{}', deniedData TEXT DEFAULT '{}', FOREIGN KEY(placeId) REFERENCES places(id));
CREATE TABLE IF NOT EXISTS acl (id INTEGER PRIMARY KEY AUTOINCREMENT, accessPoint TEXT, type INTEGER DEFAULT 0, data TEXT, FOREIGN KEY(accessPoint) REFERENCES access_points(id));

2237
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"dependencies": {
"colors": "^1.4.0",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"sqlite3": "^5.1.7"
},
"name": "notxcs",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

View file

171
routes/api/v1/ingame.js Normal file
View file

@ -0,0 +1,171 @@
const db = global.db
const express = require('express');
const router = express.Router();
module.exports = router;
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" });
}
let resp = {
success: true,
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: !!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.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" });
}
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);
let grant_type;
let granted = false;
// Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank, 4 = Allow All
// Check user ID, then group ranks, then card numbers.
for (const entry of aclEntries) {
switch (entry.type) {
case 0: // User ID
if (entry.data == userId) {
granted = true;
}
break;
case 1: // Card Number
if (cardNumbers.includes(entry.data)) {
granted = true;
}
break;
case 2: { // Group Exact Rank
const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank == rank)) {
granted = true;
}
break;
}
case 3: { // Group Min Rank
const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank >= rank)) {
granted = true;
}
break;
}
case 4: // Allow All
granted = true;
break;
}
if (granted) break;
}
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);
});
});
});
});