I'm going to shoot someone
This commit is contained in:
commit
2fcdfa203c
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal 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
66
index.js
Normal 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
56
migrations.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
5
migrations/0001_create_tables.sql
Normal file
5
migrations/0001_create_tables.sql
Normal 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
2237
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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"
|
||||||
|
}
|
||||||
0
routes/api/v1/frontend.js
Normal file
0
routes/api/v1/frontend.js
Normal file
171
routes/api/v1/ingame.js
Normal file
171
routes/api/v1/ingame.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue