diff --git a/.github/workflows/server-healthcheck.yml b/.github/workflows/server-healthcheck.yml new file mode 100644 index 0000000..0f3d4a4 --- /dev/null +++ b/.github/workflows/server-healthcheck.yml @@ -0,0 +1,24 @@ +name: Server Healthcheck + +on: + workflow_dispatch: + schedule: + - cron: '*/15 * * * *' # every 15 minutes + +jobs: + healthcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install dependencies + run: | + cd database + npm ci --silent || true + - name: Run healthcheck + run: | + cd database + node healthcheck.js diff --git a/database/backend.log b/database/backend.log index 5d762ff..2cbb59e 100644 --- a/database/backend.log +++ b/database/backend.log @@ -161,3 +161,22 @@ Connected to MongoDB [2025-08-15T08:12:28.154Z] API: Fetch all players (public) Found 3 players [2025-08-15T08:12:35.062Z] SESSION: Logout success dc5481ec7357cc3b25cfaa2d10bbbbe65496c6645eb0368bb5369d7f13d732a7 [2025-08-15T08:12:35.100Z] API: Fetch all players (public) Found 3 players +[2025-08-15T08:48:12.845Z] API: Fetch all players (public) Found 3 players +[2025-08-15T08:48:15.381Z] SESSION: Login success christoffer 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7 +[2025-08-15T08:48:15.392Z] SESSION: Validate success 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7 christoffer +[2025-08-15T08:49:04.328Z] SESSION: Logout success 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7 +[2025-08-15T08:49:12.186Z] API: Fetch all players (public) Found 3 players +[2025-08-15T08:49:20.129Z] SESSION: Login success christoffer 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad +[2025-08-15T08:49:20.138Z] API: Fetch all players (public) Found 3 players +[2025-08-15T08:49:20.142Z] SESSION: Validate success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad christoffer +[2025-08-15T09:07:28.709Z] SESSION: Validate success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad christoffer +[2025-08-15T09:07:30.722Z] SESSION: Logout success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad +[2025-08-15T09:07:36.074Z] API: Fetch all players (public) Found 3 players +[2025-08-15T09:07:44.071Z] SESSION: Login success christoffer 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 +[2025-08-15T09:07:44.079Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer +[2025-08-15T09:08:57.772Z] API: Fetch all players (public) Found 3 players +[2025-08-15T09:09:30.592Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer +[2025-08-15T09:09:31.684Z] API: Fetch all players (public) Found 3 players +[2025-08-15T09:10:12.600Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer +[2025-08-15T09:11:44.183Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer +[2025-08-15T09:12:00.469Z] API: Fetch all players (public) Found 3 players diff --git a/database/deploy/deathwatch.service.template b/database/deploy/deathwatch.service.template new file mode 100644 index 0000000..9e96b5a --- /dev/null +++ b/database/deploy/deathwatch.service.template @@ -0,0 +1,18 @@ +[Unit] +Description=Deathwatch Roller API +After=network.target + +[Service] +Type=simple +User={{RUN_AS_USER}} +WorkingDirectory={{REPO_DIR}}/database +ExecStart=/usr/bin/node {{REPO_DIR}}/database/server.js +Restart=always +RestartSec=5 +EnvironmentFile=/etc/default/deathwatch-server +StandardOutput=journal +StandardError=journal +SyslogIdentifier=deathwatch-server + +[Install] +WantedBy=multi-user.target diff --git a/database/deploy/install-systemd.sh b/database/deploy/install-systemd.sh new file mode 100644 index 0000000..3eb26be --- /dev/null +++ b/database/deploy/install-systemd.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./install-systemd.sh /absolute/path/to/repo username +# Example: ./install-systemd.sh /home/alex/git/dwroller alex + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 /absolute/path/to/repo username" + exit 2 +fi + +REPO_DIR=$1 +RUN_AS_USER=$2 +SERVICE_NAME=deathwatch-server +TEMPLATE=${REPO_DIR}/database/deploy/deathwatch.service.template +TARGET=/etc/systemd/system/${SERVICE_NAME}.service +ENV_FILE=/etc/default/${SERVICE_NAME} + +if [ "$(id -u)" -ne 0 ]; then + echo "This script must be run as root or with sudo" + exit 3 +fi + +if [ ! -f "$TEMPLATE" ]; then + echo "Template not found: $TEMPLATE" + exit 4 +fi + +# Replace placeholders +sed "s|{{REPO_DIR}}|${REPO_DIR}|g; s|{{RUN_AS_USER}}|${RUN_AS_USER}|g" "$TEMPLATE" > "$TARGET" + +# Create an environment file with defaults if not present +if [ ! -f "$ENV_FILE" ]; then + cat > "$ENV_FILE" < p.name)); + // Close DB and exit 0 + db.close(); + process.exit(0); + } catch (err) { + console.error('Healthcheck failed:', err && err.message ? err.message : err); + try { db.close(); } catch(e){} + process.exit(2); + } +} + +run(); diff --git a/database/pm2.config.js b/database/pm2.config.js new file mode 100644 index 0000000..3208e20 --- /dev/null +++ b/database/pm2.config.js @@ -0,0 +1,14 @@ +module.exports = { + apps: [ + { + name: 'deathwatch-server', + script: './server.js', + cwd: __dirname, + watch: false, + env: { + NODE_ENV: 'production', + PORT: 5000 + } + } + ] +}; diff --git a/database/server.js b/database/server.js index c6202de..0aecb7a 100755 --- a/database/server.js +++ b/database/server.js @@ -32,6 +32,18 @@ app.get('/', (req, res) => { res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.'); }); +// Health endpoint checks DB connectivity and players +app.get('/api/health', (req, res) => { + try { + const { playerHelpers } = require('./sqlite-db'); + const players = playerHelpers.getAll(); + res.json({ ok: true, players: players.length, sample: players.slice(0,5).map(p=>p.name) }); + } catch (err) { + console.error('Healthcheck error', err); + res.status(500).json({ ok: false, error: err && err.message ? err.message : String(err) }); + } +}); + // Use routes app.use('/api/players', playerRoutes); app.use('/api/sessions', sessionRoutes); @@ -42,4 +54,9 @@ app.listen(PORT, '0.0.0.0', () => { console.log('Using SQLite database'); }); +// Keep the process alive reliably under systemd (avoid accidental exit) +if (process.stdin && typeof process.stdin.resume === 'function') { + process.stdin.resume(); +} + module.exports = app; diff --git a/database/sqlite/deathwatch.db-shm b/database/sqlite/deathwatch.db-shm new file mode 100644 index 0000000..5a9538a Binary files /dev/null and b/database/sqlite/deathwatch.db-shm differ diff --git a/database/sqlite/deathwatch.db-wal b/database/sqlite/deathwatch.db-wal new file mode 100644 index 0000000..67d623d Binary files /dev/null and b/database/sqlite/deathwatch.db-wal differ diff --git a/src/App.js b/src/App.js index 633f0d3..e8d1a61 100755 --- a/src/App.js +++ b/src/App.js @@ -1,4 +1,3 @@ - import './App.css'; import DeathwatchRoller from './components/DeathwatchRoller'; import RequisitionShop from './components/RequisitionShop'; @@ -16,19 +15,26 @@ function App() { const [loginPw, setLoginPw] = useState(''); const [loginMsg, setLoginMsg] = useState(''); const [players, setPlayers] = useState([]); + const [playersError, setPlayersError] = useState(''); + // Global GM state (moved from PlayerTab) + const GM_PASSWORD = 'bongo' + const [gmOpen, setGmOpen] = useState(() => { + try { return JSON.parse(localStorage.getItem('dw:gm:session')) || false } catch { return false } + }); + const [gmPassInput, setGmPassInput] = useState(''); - // Fetch player list for login - useEffect(() => { - async function fetchPlayers() { - try { - const res = await axios.get('/api/players'); - setPlayers(res.data); - } catch { - setPlayers([]); - } + // Fetch player list for login (exposed so UI can refresh) + async function fetchPlayers() { + try { + setPlayersError('') + const res = await axios.get('/api/players'); + setPlayers(res.data || []); + } catch (err) { + console.error('fetchPlayers failed', err); + setPlayers([]); + setPlayersError('Failed to load users — is the backend running? Check console/network.'); } - fetchPlayers(); - }, []); + } // Validate session on mount/refresh useEffect(() => { @@ -134,6 +140,17 @@ function App() { setTimeout(() => setLoginMsg(''), 3000); } + function handleGmUnlock() { + if ((gmPassInput || '').trim() === GM_PASSWORD) { + setGmOpen(true); + localStorage.setItem('dw:gm:session', JSON.stringify(true)); + } else { + setGmOpen(false); + localStorage.setItem('dw:gm:session', JSON.stringify(false)); + } + setGmPassInput(''); + } + return (
{/* Persistent Header */} @@ -180,6 +197,18 @@ function App() { {loginMsg && {loginMsg}}
)} + {/* GM quick unlock in header */} +
+ setGmPassInput(e.target.value)} + onKeyPress={(e)=>{ if (e.key==='Enter') handleGmUnlock() }} + /> + +
@@ -212,9 +241,19 @@ function App() { {/* User Info Card for easy access */} {!authedPlayer && (
-

Available Users

+
+

Available Users

+
+ +
+
+ {playersError ? ( +
+ {playersError} +
+ ) : null}
- {players.map(player => ( + {players && players.length > 0 ? players.map(player => ( - ))} + )) : ( +
No users found. Click Refresh or check backend.
+ )}

Password for all users: 1234

)} - {tab==='roller' ? : tab==='shop' ? : } + {tab==='roller' ? : tab==='shop' ? : } ); diff --git a/src/components/PlayerTab.jsx b/src/components/PlayerTab.jsx index 96bb3ed..f8413ee 100755 --- a/src/components/PlayerTab.jsx +++ b/src/components/PlayerTab.jsx @@ -5,8 +5,7 @@ import { debug, info, warn, logApiCall, logApiError, logUserAction } from '../ut const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer'; const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1'; - - +const GM_PASSWORD = 'bongo'; function safeGet(key) { try { @@ -314,7 +313,8 @@ function PlayerTab({ authedPlayer, sessionId }) { // Added GM Panel button and Requisition Points display const [gmOpen, setGmOpen] = useState(false); - + const [gmPassInput, setGmPassInput] = useState(''); + // Define shopAuthed for use in the component const shopAuthed = getShopAuthed(); @@ -330,6 +330,161 @@ function PlayerTab({ authedPlayer, sessionId }) { safeSet('dw:gm:session', gmOpen); }, [gmOpen]); + // GM unlock handler (simple client-side check) + function handleGmUnlock() { + if ((gmPassInput || '').trim() === GM_PASSWORD) { + setGmOpen(true); + flash('GM unlocked'); + } else { + flash('Bad GM password'); + } + setGmPassInput(''); + } + + // GM delete handler + async function gmDeletePlayer(name) { + try { + logApiCall('PlayerTab', 'DELETE', `/api/players/${name}`) + await axios.delete(`/api/players/${name}`, { headers: { 'x-session-id': sessionId || '' } }); + const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } }); + setPlayers(res.data); + flash('Player deleted'); + } catch (e) { + logApiError('PlayerTab', 'DELETE', `/api/players/${name}`, e); + flash('Failed to delete player'); + } + } + + // Early return if not authenticated + if (!authedPlayer && !gmOpen) { + return ( +
+
+
+

Player Tab

+

Please log in to access player data

+
+ + {/* Small GM login panel under global login */} +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ +
+
+ ); + } + + // GM management components + function GmAddPlayer({ onAdd }) { + const [name, setName] = useState('') + const [rp, setRp] = useState(10) + const [pw, setPw] = useState('') + return ( +
+ setName(e.target.value)} /> + setRp(parseInt(e.target.value||'0'))} /> + setPw(e.target.value)} /> + +
Add or overwrite by name
+
+ ) + } + function GmSetRP({ name, onSet }) { + const [rp, setRp] = useState('') + return ( +
+ setRp(e.target.value)} /> + +
+ ) + } + function GmSetRenown({ name, value, onSet }) { + const [renown, setRenown] = useState(value) + return ( +
+ + +
+ ) + } + function GmResetPW({ name, onReset }) { + const [pw, setPw] = useState('') + return ( +
+ setPw(e.target.value)} /> + +
+ ) + } + + // GM action handlers + async function gmAddOrUpdatePlayer(name, rp, pw) { + try { + logApiCall('PlayerTab', 'POST', '/api/players') + await axios.post('/api/players', { name, rp, pw }, { headers: { 'x-session-id': sessionId || '' } }); + const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } }); + setPlayers(res.data); + flash('Player added/updated'); + } catch (e) { + logApiError('PlayerTab', 'POST', '/api/players', e); + flash('Failed to add player'); + } + } + async function gmSetRP(name, rp) { + try { + logApiCall('PlayerTab', 'PUT', `/api/players/${name}/rp`) + await axios.put(`/api/players/${name}/rp`, { rp }, { headers: { 'x-session-id': sessionId || '' } }); + const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } }); + setPlayers(res.data); + flash('RP updated'); + } catch (e) { + logApiError('PlayerTab', 'PUT', `/api/players/${name}/rp`, e); + flash('Failed to set RP'); + } + } + async function gmSetRenown(name, renown) { + try { + logApiCall('PlayerTab', 'PUT', `/api/players/${name}/renown`) + await axios.put(`/api/players/${name}/renown`, { renown }, { headers: { 'x-session-id': sessionId || '' } }); + const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } }); + setPlayers(res.data); + flash('Renown updated'); + } catch (e) { + logApiError('PlayerTab', 'PUT', `/api/players/${name}/renown`, e); + flash('Failed to set renown'); + } + } + async function gmResetPlayerPw(name, pw) { + try { + logApiCall('PlayerTab', 'PUT', `/api/players/${name}/pw`) + await axios.put(`/api/players/${name}/pw`, { pw }, { headers: { 'x-session-id': sessionId || '' } }); + const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } }); + setPlayers(res.data); + flash('Password reset'); + } catch (e) { + logApiError('PlayerTab', 'PUT', `/api/players/${name}/pw`, e); + flash('Failed to reset password'); + } + } + + const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero'] + function normalizeRank(r) { const s = String(r||'').trim(); const found = RANK_ORDER.find(x=>x.toLowerCase()===s.toLowerCase()); return found || (s? s : 'None') } + function renownClass(r) { const rr = normalizeRank(r); if (rr==='Respected') return 'bg-emerald-700'; if (rr==='Distinguished') return 'bg-indigo-700'; if (rr==='Famed') return 'bg-purple-700'; if (rr==='Hero') return 'bg-rose-700'; return 'bg-slate-700' } + // No per-tab login/logout; handled globally // Early return if not authenticated @@ -341,6 +496,23 @@ function PlayerTab({ authedPlayer, sessionId }) {

Player Tab

Please log in to access player data

+ + {/* Small GM login panel under global login */} +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ ); @@ -349,7 +521,25 @@ function PlayerTab({ authedPlayer, sessionId }) { return (
- + + {/* Small GM login panel (visible under global header) */} + {!gmOpen && ( +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ )} + {/* Character Name & Player Name */}
@@ -653,6 +843,64 @@ function PlayerTab({ authedPlayer, sessionId }) {