- New database/auth.js: shared server-side GM check using session store - database/server.js: crypto-random session generation, server-side session storage, GM auth from session instead of x-gm-secret header - database/routes/playerRoutes.js: GM-only endpoints use server-side auth - database/routes/sessionRoutes.js: session creation/lookup endpoints - database/routes/bestiaryRoutes.js: GM auth from server-side session - database/routes/rulesRoutes.js: GM auth from server-side session - src/App.js: send x-session-id header with API requests - src/components/BestiaryTab.jsx: use session-based GM auth
378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const bcrypt = require('bcrypt');
|
|
const { playerHelpers, logToFile } = require('../mariadb');
|
|
const { requireGM } = require('../auth');
|
|
const router = express.Router();
|
|
|
|
async function isValidPlayerPassword(player, password) {
|
|
if (player.pwHash) {
|
|
return bcrypt.compare(password, player.pwHash);
|
|
}
|
|
|
|
const expectedPassword = player.pw || process.env.PLAYER_PASSWORD || '1234';
|
|
return password === expectedPassword;
|
|
}
|
|
|
|
// Login endpoint for players
|
|
router.post('/login', async (req, res) => {
|
|
try {
|
|
const { name, password } = req.body;
|
|
console.log('Login attempt for player:', name);
|
|
|
|
if (!name || !password) {
|
|
return res.status(400).json({ error: 'Please enter both username and password' });
|
|
}
|
|
|
|
// Check if player exists first
|
|
const player = await playerHelpers.getByName(name);
|
|
if (!player) {
|
|
// Return generic message for security (don't reveal if user exists)
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
// Special handling for GM user
|
|
if (name.toLowerCase() === 'gm') {
|
|
const gmPassword = process.env.GM_PASSWORD || 'bongo';
|
|
if (password !== gmPassword) {
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
} else {
|
|
if (!(await isValidPlayerPassword(player, password))) {
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
}
|
|
|
|
// Generate a cryptographically random session ID (no player name, no timestamp)
|
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
|
|
// Store session in database with expiration
|
|
await require('../sessionModel').createSession(sessionId, { playerName: name, isGM: name.toLowerCase() === 'gm' }, expiresAt);
|
|
|
|
logToFile('API: Player login', name, 'success');
|
|
res.json({
|
|
success: true,
|
|
sessionId,
|
|
player: { name: player.name },
|
|
expiresAt: expiresAt.toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
logToFile('API: Failed to login player', req.body?.name, error);
|
|
res.status(500).json({ error: 'Login failed. Please try again later.' });
|
|
}
|
|
});
|
|
|
|
// Get player names for login dropdown (public - no session required)
|
|
router.get('/names', async (req, res) => {
|
|
try {
|
|
console.log('Player names endpoint hit');
|
|
const players = await playerHelpers.getAll();
|
|
// Only return names for the login dropdown, not full player data
|
|
const playerNames = players.map(p => ({ name: p.name }));
|
|
res.json(playerNames);
|
|
} catch (error) {
|
|
console.error('Player names error:', error);
|
|
logToFile('API: Failed to get player names', error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
// Get all players (public for dropdown)
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const players = await playerHelpers.getAll();
|
|
logToFile('API: Fetch all players (public)', players.length);
|
|
|
|
// If authenticated (has session header), return full player data
|
|
const hasSession = req.headers['x-session-id'];
|
|
|
|
if (hasSession) {
|
|
// Return full player data for authenticated requests
|
|
res.json(players);
|
|
} else {
|
|
// Only send name for dropdown if not authenticated
|
|
res.json(players.map(p => ({ name: p.name })));
|
|
}
|
|
} catch (error) {
|
|
logToFile('API: Failed to fetch players', error);
|
|
res.status(500).json({ error: 'Failed to fetch players' });
|
|
}
|
|
});
|
|
|
|
// Get player by name
|
|
router.get('/:name', async (req, res) => {
|
|
try {
|
|
const { name } = req.params;
|
|
console.log('Getting player:', name);
|
|
|
|
const player = await playerHelpers.getByName(name);
|
|
if (!player) {
|
|
return res.status(404).json({ error: 'Player not found' });
|
|
}
|
|
|
|
logToFile('API: Fetch player', name, 'success');
|
|
res.json(player);
|
|
} catch (error) {
|
|
console.error('Get player error:', error);
|
|
logToFile('API: Failed to get player', req.params.name, error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
// Update player data
|
|
router.put('/:name', async (req, res) => {
|
|
try {
|
|
const { name } = req.params;
|
|
const playerData = req.body;
|
|
|
|
console.log('Updating player:', name);
|
|
|
|
const success = await playerHelpers.update(name, playerData);
|
|
if (!success) {
|
|
return res.status(500).json({ error: 'Failed to update player' });
|
|
}
|
|
|
|
logToFile('API: Updated player', name, 'success');
|
|
res.json({ success: true, message: 'Player updated successfully' });
|
|
} catch (error) {
|
|
console.error('Update player error:', error);
|
|
logToFile('API: Failed to update player', req.params.name, error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
// Spend a single fate point (atomic read-modify-write, preserves other fields)
|
|
router.post('/:name/spend-fate', async (req, res) => {
|
|
try {
|
|
const { name } = req.params;
|
|
const player = await playerHelpers.getByName(name);
|
|
if (!player) return res.status(404).json({ error: 'Player not found' });
|
|
|
|
const tabInfo = player.tabInfo || {};
|
|
let fate = tabInfo.fate;
|
|
if (typeof fate !== 'object' || fate === null) {
|
|
const n = Number(fate) || 0;
|
|
fate = { current: n, total: n };
|
|
}
|
|
const current = Number(fate.current) || 0;
|
|
if (current <= 0) return res.status(400).json({ error: 'No fate points remaining' });
|
|
|
|
const newFate = { ...fate, current: current - 1 };
|
|
const ok = await playerHelpers.update(name, {
|
|
rollerInfo: player.rollerInfo,
|
|
shopInfo: player.shopInfo,
|
|
tabInfo: { ...tabInfo, fate: newFate },
|
|
pw: player.pw,
|
|
pwHash: player.pwHash,
|
|
});
|
|
if (!ok) return res.status(500).json({ error: 'Failed to update fate' });
|
|
|
|
logToFile('API: Spent fate point', name, `${current} -> ${newFate.current}`);
|
|
res.json({ success: true, fate: newFate });
|
|
} catch (error) {
|
|
console.error('Spend fate error:', error);
|
|
logToFile('API: Failed to spend fate', req.params.name, error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
// Create new player
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const playerData = req.body;
|
|
|
|
console.log('Creating player:', playerData.name);
|
|
|
|
// Check if player already exists
|
|
const existing = await playerHelpers.getByName(playerData.name);
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'Player already exists' });
|
|
}
|
|
|
|
const playerId = await playerHelpers.create(playerData);
|
|
if (!playerId) {
|
|
return res.status(500).json({ error: 'Failed to create player' });
|
|
}
|
|
|
|
logToFile('API: Created player', playerData.name, 'success');
|
|
res.json({ success: true, id: playerId, message: 'Player created successfully' });
|
|
} catch (error) {
|
|
console.error('Create player error:', error);
|
|
logToFile('API: Failed to create player', req.body.name, error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
// --- GM Endpoints (require server-side session with playerName === 'gm') ---
|
|
|
|
// 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 (!(await 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 (!(await 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 (!(await 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 (!(await 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 (!(await requireGM(req, res))) return;
|
|
try {
|
|
const { name, requisitionPoints, password, charName } = 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,
|
|
charName: charName || name
|
|
}
|
|
});
|
|
logToFile('API: GM added player', name);
|
|
} else {
|
|
const updatedTabInfo = { rp: parseInt(requisitionPoints) || 0 };
|
|
if (charName) updatedTabInfo.charName = charName;
|
|
await updateTabInfo(name, updatedTabInfo);
|
|
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 (!(await 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 (!(await 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 {
|
|
const { name } = req.params;
|
|
|
|
console.log('Deleting player:', name);
|
|
|
|
const success = await playerHelpers.delete(name);
|
|
if (!success) {
|
|
return res.status(404).json({ error: 'Player not found or could not be deleted' });
|
|
}
|
|
|
|
logToFile('API: Deleted player', name, 'success');
|
|
res.json({ success: true, message: 'Player deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Delete player error:', error);
|
|
logToFile('API: Failed to delete player', req.params.name, error);
|
|
res.status(500).json({ error: String(error) });
|
|
}
|
|
});
|
|
|
|
console.log('Player routes registered (MariaDB)');
|
|
module.exports = router; |