Fix missing DB table, rules column, GM endpoints, and logout

- Add rules table to createTables() with auto-seed from rules-database.json
- Fix page_num → page column mapping in rulesRoutes.js
- Fix INSERT column names in rulesHelpers.create() and stagingHelpers
- Add POST /api/sessions/logout endpoint
- Add GM endpoints: set-xp, set-xp-spent, set-rp, set-renown,
  add-or-update, reset-password, DELETE gm/delete/:name
- All GM endpoints require x-gm-secret header (403 otherwise)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 18:07:31 +01:00
parent 96d471b2f8
commit 43d0becdf6
4 changed files with 233 additions and 21 deletions

View File

@@ -73,15 +73,34 @@ const createTables = async () => {
)
`);
// Rules table
await connection.execute(`
CREATE TABLE IF NOT EXISTS rules (
id INT AUTO_INCREMENT PRIMARY KEY,
rule_id VARCHAR(255) UNIQUE,
title VARCHAR(500),
content TEXT,
page INT,
source VARCHAR(255),
source_abbr VARCHAR(50),
category VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_rules_staging_category ON rules_staging(category)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_rules_category ON rules(category)`);
connection.release();
console.log('MariaDB tables created successfully');
logToFile('MariaDB: Tables created successfully');
// Seed rules from JSON if table is empty
await seedRulesIfEmpty();
} catch (error) {
console.error('Error creating MariaDB tables:', error);
logToFile('MariaDB: Error creating tables', error);
@@ -89,6 +108,38 @@ const createTables = async () => {
}
};
// Seed rules table from rules-database.json if empty
const seedRulesIfEmpty = async () => {
try {
const [countRows] = await pool.execute('SELECT COUNT(*) AS cnt FROM rules');
if (countRows[0].cnt > 0) return;
const dbJsonPath = path.join(__dirname, 'rules', 'rules-database.json');
if (!fs.existsSync(dbJsonPath)) {
logToFile('MariaDB: rules-database.json not found, skipping seed');
return;
}
const data = JSON.parse(fs.readFileSync(dbJsonPath, 'utf8'));
const rules = data.rules || [];
if (!rules.length) return;
const connection = await pool.getConnection();
for (const rule of rules) {
await connection.execute(
'INSERT IGNORE INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
[rule.id || null, rule.title || '', rule.content || '', rule.page || null, rule.source || null, rule.sourceAbbr || null, rule.category || null]
);
}
connection.release();
console.log(`MariaDB: Seeded ${rules.length} rules from rules-database.json`);
logToFile(`MariaDB: Seeded ${rules.length} rules from rules-database.json`);
} catch (error) {
console.error('Error seeding rules:', error);
logToFile('MariaDB: Error seeding rules', error);
}
};
// Player helpers
const playerHelpers = {
getAll: async () => {
@@ -274,8 +325,8 @@ const rulesHelpers = {
create: async (rule) => {
try {
const [result] = await pool.execute(
'INSERT INTO rules (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
'INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
[rule.rule_id || null, rule.title, rule.content, rule.page || null, rule.source || null, rule.source_abbr || null, rule.category || null]
);
return result.insertId;
} catch (error) {
@@ -310,8 +361,8 @@ const stagingHelpers = {
create: async (rule) => {
try {
const [result] = await pool.execute(
'INSERT INTO rules_staging (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
'INSERT INTO rules_staging (title, content, category, page, original_json) VALUES (?, ?, ?, ?, ?)',
[rule.title, rule.content, rule.category || null, rule.page || null, rule.original_json || null]
);
return result.insertId;
} catch (error) {
@@ -347,8 +398,8 @@ const stagingHelpers = {
// Insert into rules
await connection.execute(
'INSERT INTO rules (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
'INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
[rule.rule_id || null, rule.title, rule.content, rule.page || null, rule.source || null, rule.source_abbr || null, rule.category || null]
);
// Delete from staging

View File

@@ -153,6 +153,153 @@ router.post('/', async (req, res) => {
}
});
// --- GM Endpoints (require x-gm-secret header) ---
function requireGM(req, res) {
if (req.headers['x-gm-secret'] !== 'bongo') {
res.status(403).json({ error: 'Unauthorized' });
return false;
}
return true;
}
// Helper: update a field inside tab_info JSON
async function updateTabInfo(name, fields) {
const player = await playerHelpers.getByName(name);
if (!player) return null;
const tabInfo = { ...(player.tabInfo || {}), ...fields };
await playerHelpers.update(name, {
rollerInfo: player.rollerInfo,
shopInfo: player.shopInfo,
tabInfo,
pw: player.pw,
pwHash: player.pwHash,
});
return true;
}
// Set XP for a player
router.post('/gm/set-xp', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { playerName, xp } = req.body;
const ok = await updateTabInfo(playerName, { xp: parseInt(xp) || 0 });
if (!ok) return res.status(404).json({ error: 'Player not found' });
logToFile('API: GM set XP', playerName, xp);
res.json({ success: true });
} catch (e) {
logToFile('API: GM set XP error', e);
res.status(500).json({ error: String(e) });
}
});
// Set XP Spent for a player
router.post('/gm/set-xp-spent', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { playerName, xpSpent } = req.body;
const ok = await updateTabInfo(playerName, { xpSpent: parseInt(xpSpent) || 0 });
if (!ok) return res.status(404).json({ error: 'Player not found' });
logToFile('API: GM set XP spent', playerName, xpSpent);
res.json({ success: true });
} catch (e) {
logToFile('API: GM set XP spent error', e);
res.status(500).json({ error: String(e) });
}
});
// Set Requisition Points for a player
router.post('/gm/set-rp', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { playerName, requisitionPoints } = req.body;
const ok = await updateTabInfo(playerName, { rp: parseInt(requisitionPoints) || 0 });
if (!ok) return res.status(404).json({ error: 'Player not found' });
logToFile('API: GM set RP', playerName, requisitionPoints);
res.json({ success: true });
} catch (e) {
logToFile('API: GM set RP error', e);
res.status(500).json({ error: String(e) });
}
});
// Set Renown for a player
router.post('/gm/set-renown', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { playerName, renown } = req.body;
const ok = await updateTabInfo(playerName, { renown });
if (!ok) return res.status(404).json({ error: 'Player not found' });
logToFile('API: GM set renown', playerName, renown);
res.json({ success: true });
} catch (e) {
logToFile('API: GM set renown error', e);
res.status(500).json({ error: String(e) });
}
});
// Add or update a player (create if not exists, set RP)
router.post('/gm/add-or-update', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { name, requisitionPoints, password } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
let player = await playerHelpers.getByName(name);
if (!player) {
await playerHelpers.create({ name, pw: password || '1234', tabInfo: { rp: parseInt(requisitionPoints) || 0 } });
logToFile('API: GM added player', name);
} else {
await updateTabInfo(name, { rp: parseInt(requisitionPoints) || 0 });
if (password) {
await playerHelpers.update(name, { rollerInfo: player.rollerInfo, shopInfo: player.shopInfo, tabInfo: player.tabInfo, pw: password, pwHash: '' });
}
logToFile('API: GM updated player', name);
}
res.json({ success: true });
} catch (e) {
logToFile('API: GM add-or-update error', e);
res.status(500).json({ error: String(e) });
}
});
// Reset player password
router.post('/gm/reset-password', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { playerName, newPassword } = req.body;
const player = await playerHelpers.getByName(playerName);
if (!player) return res.status(404).json({ error: 'Player not found' });
await playerHelpers.update(playerName, {
rollerInfo: player.rollerInfo,
shopInfo: player.shopInfo,
tabInfo: player.tabInfo,
pw: newPassword || '1234',
pwHash: '',
});
logToFile('API: GM reset password', playerName);
res.json({ success: true });
} catch (e) {
logToFile('API: GM reset password error', e);
res.status(500).json({ error: String(e) });
}
});
// Delete player (GM)
router.delete('/gm/delete/:name', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { name } = req.params;
const success = await playerHelpers.delete(name);
if (!success) return res.status(404).json({ error: 'Player not found or could not be deleted' });
logToFile('API: GM deleted player', name);
res.json({ success: true });
} catch (e) {
logToFile('API: GM delete player error', e);
res.status(500).json({ error: String(e) });
}
});
// Delete player
router.delete('/:name', async (req, res) => {
try {

View File

@@ -37,14 +37,14 @@ async function getAllRules() {
try {
const rows = await rulesHelpers.getAll();
return rows.map(r => ({
id: r.id,
rule_id: r.rule_id,
title: cleanTitle(r.title),
content: cleanText(r.content),
page: r.page_num,
source: r.source,
sourceAbbr: r.source_abbr,
category: r.category
id: r.id,
rule_id: r.rule_id,
title: cleanTitle(r.title),
content: cleanText(r.content),
page: r.page,
source: r.source,
sourceAbbr: r.source_abbr,
category: r.category
}));
} catch (e) {
console.error('Failed to read rules from MariaDB:', e);
@@ -59,13 +59,13 @@ async function getRuleById(ruleId) {
const row = rows.find(r => r.rule_id === ruleId || r.id === ruleId);
if (!row) return null;
return {
id: row.rule_id || row.id,
title: cleanTitle(row.title),
content: cleanText(row.content),
page: row.page_num,
source: row.source,
sourceAbbr: row.source_abbr,
category: row.category
id: row.rule_id || row.id,
title: cleanTitle(row.title),
content: cleanText(row.content),
page: row.page,
source: row.source,
sourceAbbr: row.source_abbr,
category: row.category
};
} catch (e) {
console.error('Failed to read rule by id:', e);

View File

@@ -33,5 +33,19 @@ router.post('/validate', async (req, res) => {
}
});
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const { sessionId } = req.body;
if (sessionId) {
logToFile('SESSION: Logout', sessionId);
}
res.json({ success: true });
} catch (error) {
logToFile('SESSION: Logout error', error);
res.status(500).json({ error: String(error) });
}
});
console.log('Session routes registered (simple validation)');
module.exports = router;