// spacel leaderboard server // Setup: cd server && npm install && node server.js // Config: set env var SPACEL_SECRET to the same value as HMAC_SECRET in online_leaderboard.gd // set env var PORT to override default 3000 const express = require('express'); const Database = require('better-sqlite3'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const app = express(); const db = new Database(process.env.DB_PATH || 'scores.db'); const SECRET = process.env.SPACEL_SECRET || 'CHANGE-ME-BEFORE-DEPLOY'; const PORT = parseInt(process.env.PORT || '3000', 10); db.exec(` CREATE TABLE IF NOT EXISTS scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score INTEGER NOT NULL CHECK(score >= 0 AND score <= 500000), wave INTEGER NOT NULL CHECK(wave >= 1 AND wave <= 100), created_at TEXT NOT NULL ) `); app.use(express.json({ limit: '4kb' })); const submitLimit = rateLimit({ windowMs: 10 * 60 * 1000, max: 1, standardHeaders: true, legacyHeaders: false, message: { error: 'Rate limit: 1 submission per 10 minutes per IP.' } }); // GET /scores — top 10 by score app.get('/scores', (_req, res) => { const rows = db .prepare('SELECT name, score, wave, created_at FROM scores ORDER BY score DESC LIMIT 10') .all(); res.json(rows); }); // POST /scores — submit a new score // Body: { name, score, wave, timestamp, hmac } // HMAC = SHA256(SECRET, name + score + wave + timestamp) app.post('/scores', submitLimit, (req, res) => { const { name, score, wave, timestamp, hmac } = req.body ?? {}; if (name == null || score == null || wave == null || timestamp == null || hmac == null) return res.status(400).json({ error: 'Missing fields' }); if (typeof name !== 'string' || !Number.isInteger(score) || !Number.isInteger(wave) || !Number.isInteger(timestamp) || typeof hmac !== 'string') return res.status(400).json({ error: 'Invalid field types' }); // Reject stale timestamps (±5 minutes) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 300) return res.status(400).json({ error: 'Timestamp expired' }); if (score < 0 || score > 500000) return res.status(400).json({ error: 'Score out of range' }); if (wave < 1 || wave > 100) return res.status(400).json({ error: 'Wave out of range' }); const cleanName = name.trim().slice(0, 12); if (cleanName.length === 0) return res.status(400).json({ error: 'Name required' }); // Verify HMAC — must match what online_leaderboard.gd sends const raw = name + String(score) + String(wave) + String(timestamp); const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex'); let valid = false; try { // timingSafeEqual requires equal-length buffers; mismatch length → invalid if (hmac.length === expected.length) { valid = crypto.timingSafeEqual( Buffer.from(hmac, 'hex'), Buffer.from(expected, 'hex') ); } } catch { /* invalid hex */ } if (!valid) return res.status(403).json({ error: 'Invalid signature' }); db.prepare( 'INSERT INTO scores (name, score, wave, created_at) VALUES (?, ?, ?, ?)' ).run(cleanName, score, wave, new Date().toISOString().split('T')[0]); res.json({ ok: true }); }); app.listen(PORT, () => console.log(`spacel leaderboard running on port ${PORT}`) );